mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-07 08:09:01 +02:00
87c9c7a455
[web] Unlock self-served license purchasing for legacy plans GitOrigin-RevId: bf3083d00a77417f0e78d2145f6192c57b163273
453 lines
12 KiB
JavaScript
453 lines
12 KiB
JavaScript
// @ts-check
|
|
|
|
const OError = require('@overleaf/o-error')
|
|
const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors')
|
|
const PlansLocator = require('./PlansLocator')
|
|
const SubscriptionHelper = require('./SubscriptionHelper')
|
|
|
|
const AI_ADD_ON_CODE = 'assistant'
|
|
const MEMBERS_LIMIT_ADD_ON_CODE = 'additional-license'
|
|
const STANDALONE_AI_ADD_ON_CODES = ['assistant', 'assistant-annual']
|
|
|
|
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
|
|
* @param {Date} props.periodStart
|
|
* @param {Date} props.periodEnd
|
|
* @param {Date} props.createdAt
|
|
* @param {RecurlySubscriptionChange} [props.pendingChange]
|
|
*/
|
|
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
|
|
this.periodStart = props.periodStart
|
|
this.periodEnd = props.periodEnd
|
|
this.createdAt = props.createdAt
|
|
this.pendingChange = props.pendingChange ?? null
|
|
}
|
|
|
|
/**
|
|
* Returns whether this subscription currently has the given add-on
|
|
*
|
|
* @param {string} code
|
|
* @return {boolean}
|
|
*/
|
|
hasAddOn(code) {
|
|
return this.addOns.some(addOn => addOn.code === code)
|
|
}
|
|
|
|
/**
|
|
* Returns whether this subscription is a standalone AI add-on subscription
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
isStandaloneAiAddOn() {
|
|
return isStandaloneAiAddOnPlanCode(this.planCode)
|
|
}
|
|
|
|
/**
|
|
* Returns whether this subcription will have the given add-on next billing
|
|
* period.
|
|
*
|
|
* There are two cases: either the subscription already has the add-on and
|
|
* won't change next period, or the subscription will change next period and
|
|
* the change includes the add-on.
|
|
*
|
|
* @param {string} code
|
|
* @return {boolean}
|
|
*/
|
|
hasAddOnNextPeriod(code) {
|
|
if (this.pendingChange != null) {
|
|
return this.pendingChange.nextAddOns.some(addOn => addOn.code === code)
|
|
} else {
|
|
return this.hasAddOn(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 shouldChangeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
|
|
currentPlan,
|
|
newPlan
|
|
)
|
|
|
|
const changeRequest = new RecurlySubscriptionChangeRequest({
|
|
subscription: this,
|
|
timeframe: shouldChangeAtTermEnd ? 'term_end' : 'now',
|
|
planCode,
|
|
})
|
|
|
|
// Carry the AI add-on to the new plan if applicable
|
|
if (
|
|
this.isStandaloneAiAddOn() ||
|
|
(!shouldChangeAtTermEnd && this.hasAddOn(AI_ADD_ON_CODE)) ||
|
|
(shouldChangeAtTermEnd && this.hasAddOnNextPeriod(AI_ADD_ON_CODE))
|
|
) {
|
|
const addOnUpdate = new RecurlySubscriptionAddOnUpdate({
|
|
code: AI_ADD_ON_CODE,
|
|
quantity: 1,
|
|
})
|
|
changeRequest.addOnUpdates = [addOnUpdate]
|
|
}
|
|
|
|
return changeRequest
|
|
}
|
|
|
|
/**
|
|
* Purchase an add-on on this subscription
|
|
*
|
|
* @param {string} code
|
|
* @param {number} [quantity]
|
|
* @param {number} [unitPrice]
|
|
* @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, unitPrice) {
|
|
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, unitPrice })
|
|
)
|
|
return new RecurlySubscriptionChangeRequest({
|
|
subscription: this,
|
|
timeframe: 'now',
|
|
addOnUpdates,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Update an add-on on this subscription
|
|
*
|
|
* @param {string} code
|
|
* @param {number} quantity
|
|
* @return {RecurlySubscriptionChangeRequest} - the change request to send to
|
|
* Recurly
|
|
*
|
|
* @throws {AddOnNotPresentError} if the subscription doesn't have the add-on
|
|
*/
|
|
getRequestForAddOnUpdate(code, quantity) {
|
|
if (!this.hasAddOn(code)) {
|
|
throw new AddOnNotPresentError(
|
|
'Subscription does not have add-on to update',
|
|
{
|
|
subscriptionId: this.id,
|
|
addOnCode: code,
|
|
}
|
|
)
|
|
}
|
|
|
|
const addOnUpdates = this.addOns.map(addOn => {
|
|
const update = addOn.toAddOnUpdate()
|
|
|
|
if (update.code === code) {
|
|
update.quantity = quantity
|
|
}
|
|
|
|
return update
|
|
})
|
|
|
|
return new RecurlySubscriptionChangeRequest({
|
|
subscription: this,
|
|
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(
|
|
'Subscription 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({
|
|
subscription: this,
|
|
timeframe: 'term_end',
|
|
addOnUpdates,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Upgrade group plan with the plan code provided
|
|
*
|
|
* @param {string} newPlanCode
|
|
* @param {number} membersLimit
|
|
* @return {RecurlySubscriptionChangeRequest}
|
|
*/
|
|
getRequestForGroupPlanUpgrade(newPlanCode, membersLimit) {
|
|
// Ensure all the existing add-ons are added to the new plan
|
|
// Except for the additional license, which will be added below
|
|
const addOns = this.addOns
|
|
.filter(addOn => addOn.code !== 'additional-license')
|
|
.map(
|
|
addOn =>
|
|
new RecurlySubscriptionAddOnUpdate({
|
|
code: addOn.code,
|
|
quantity: addOn.quantity,
|
|
})
|
|
)
|
|
|
|
// Get the number of licenses from the membersLimit field in the Subscription model
|
|
// This is necessary because legacy group plans do not fully use add-ons to represent seats
|
|
addOns.push(
|
|
new RecurlySubscriptionAddOnUpdate({
|
|
code: 'additional-license',
|
|
quantity: membersLimit,
|
|
})
|
|
)
|
|
|
|
return new RecurlySubscriptionChangeRequest({
|
|
subscription: this,
|
|
timeframe: 'now',
|
|
addOnUpdates: addOns,
|
|
planCode: newPlanCode,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An add-on attached to a subscription
|
|
*/
|
|
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
|
|
this.preTaxTotal = 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 {RecurlySubscription} props.subscription
|
|
* @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.subscription = props.subscription
|
|
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
|
|
}
|
|
}
|
|
|
|
class RecurlySubscriptionChange {
|
|
/**
|
|
* @param {object} props
|
|
* @param {RecurlySubscription} props.subscription
|
|
* @param {string} props.nextPlanCode
|
|
* @param {string} props.nextPlanName
|
|
* @param {number} props.nextPlanPrice
|
|
* @param {RecurlySubscriptionAddOn[]} props.nextAddOns
|
|
* @param {RecurlyImmediateCharge} [props.immediateCharge]
|
|
*/
|
|
constructor(props) {
|
|
this.subscription = props.subscription
|
|
this.nextPlanCode = props.nextPlanCode
|
|
this.nextPlanName = props.nextPlanName
|
|
this.nextPlanPrice = props.nextPlanPrice
|
|
this.nextAddOns = props.nextAddOns
|
|
this.immediateCharge =
|
|
props.immediateCharge ??
|
|
new RecurlyImmediateCharge({ subtotal: 0, tax: 0, total: 0 })
|
|
|
|
this.subtotal = this.nextPlanPrice
|
|
for (const addOn of this.nextAddOns) {
|
|
this.subtotal += addOn.preTaxTotal
|
|
}
|
|
|
|
this.tax = Math.round(this.subtotal * 100 * this.subscription.taxRate) / 100
|
|
|
|
this.total = this.subtotal + this.tax
|
|
}
|
|
|
|
getAddOn(addOnCode) {
|
|
return this.nextAddOns.find(addOn => addOn.code === addOnCode)
|
|
}
|
|
}
|
|
|
|
class PaypalPaymentMethod {
|
|
toString() {
|
|
return 'Paypal'
|
|
}
|
|
}
|
|
|
|
class CreditCardPaymentMethod {
|
|
/**
|
|
* @param {object} props
|
|
* @param {string} props.cardType
|
|
* @param {string} props.lastFour
|
|
*/
|
|
constructor(props) {
|
|
this.cardType = props.cardType
|
|
this.lastFour = props.lastFour
|
|
}
|
|
|
|
toString() {
|
|
return `${this.cardType} **** ${this.lastFour}`
|
|
}
|
|
}
|
|
|
|
class RecurlyImmediateCharge {
|
|
/**
|
|
* @param {object} props
|
|
* @param {number} props.subtotal
|
|
* @param {number} props.tax
|
|
* @param {number} props.total
|
|
*/
|
|
constructor(props) {
|
|
this.subtotal = props.subtotal
|
|
this.tax = props.tax
|
|
this.total = props.total
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An add-on configuration, independent of any subscription
|
|
*/
|
|
class RecurlyAddOn {
|
|
/**
|
|
* @param {object} props
|
|
* @param {string} props.code
|
|
* @param {string} props.name
|
|
*/
|
|
constructor(props) {
|
|
this.code = props.code
|
|
this.name = props.name
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A plan configuration
|
|
*/
|
|
class RecurlyPlan {
|
|
/**
|
|
* @param {object} props
|
|
* @param {string} props.code
|
|
* @param {string} props.name
|
|
*/
|
|
constructor(props) {
|
|
this.code = props.code
|
|
this.name = props.name
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns whether the given plan code is a standalone AI plan
|
|
*
|
|
* @param {string} planCode
|
|
*/
|
|
function isStandaloneAiAddOnPlanCode(planCode) {
|
|
return STANDALONE_AI_ADD_ON_CODES.includes(planCode)
|
|
}
|
|
|
|
module.exports = {
|
|
AI_ADD_ON_CODE,
|
|
MEMBERS_LIMIT_ADD_ON_CODE,
|
|
RecurlySubscription,
|
|
RecurlySubscriptionAddOn,
|
|
RecurlySubscriptionChange,
|
|
RecurlySubscriptionChangeRequest,
|
|
RecurlySubscriptionAddOnUpdate,
|
|
PaypalPaymentMethod,
|
|
CreditCardPaymentMethod,
|
|
RecurlyAddOn,
|
|
RecurlyPlan,
|
|
isStandaloneAiAddOnPlanCode,
|
|
RecurlyImmediateCharge,
|
|
}
|