Files
overleaf-cep/services/web/app/src/Features/Subscription/PaymentProviderEntities.js
T
Domagoj Kriskovic 11cb140fe3 Move AI related functions from PaymentProviderEntities to AiHelper (#26956)
* Move AI related functions from PaymentProviderEntities to AiHelper

* added @ts-check

GitOrigin-RevId: 8c8eec334b40a7f8f8533f6d5194f428112f68f9
2025-07-10 08:07:51 +00:00

613 lines
17 KiB
JavaScript

// @ts-check
/**
* @import { PaymentProvider } from '../../../../types/subscription/dashboard/subscription'
* @import { CurrencyCode, StripeCurrencyCode } from '../../../../types/subscription/currency'
* @import { AddOn } from '../../../../types/subscription/plan'
*/
const OError = require('@overleaf/o-error')
const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors')
const PlansLocator = require('./PlansLocator')
const SubscriptionHelper = require('./SubscriptionHelper')
const { AI_ADD_ON_CODE, isStandaloneAiAddOnPlanCode } = require('./AiHelper')
const MEMBERS_LIMIT_ADD_ON_CODE = 'additional-license'
class PaymentProviderSubscription {
/**
* @param {object} props
* @param {string} props.id
* @param {string} props.userId
* @param {string} props.planCode
* @param {string} props.planName
* @param {number} props.planPrice
* @param {PaymentProviderSubscriptionAddOn[]} [props.addOns]
* @param {number} props.subtotal
* @param {number} [props.taxRate]
* @param {number} [props.taxAmount]
* // Recurly uses uppercase currency codes, but Stripe uses lowercase
* @param {CurrencyCode | StripeCurrencyCode} props.currency
* @param {number} props.total
* @param {Date} props.periodStart
* @param {Date} props.periodEnd
* @param {string} props.collectionMethod
* @param {number} [props.netTerms]
* @param {string} [props.poNumber]
* @param {string} [props.termsAndConditions]
* @param {PaymentProviderSubscriptionChange} [props.pendingChange]
* @param {PaymentProvider['service']} [props.service]
* @param {string} [props.state]
* @param {Date|null} [props.trialPeriodStart]
* @param {Date|null} [props.trialPeriodEnd]
* @param {Date|null} [props.pausePeriodStart]
* @param {number|null} [props.remainingPauseCycles]
*/
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.toUpperCase() // ensure that currency codes are always uppercase
this.total = props.total
this.periodStart = props.periodStart
this.periodEnd = props.periodEnd
this.collectionMethod = props.collectionMethod
this.netTerms = props.netTerms ?? 0
this.poNumber = props.poNumber ?? ''
this.termsAndConditions = props.termsAndConditions ?? ''
this.pendingChange = props.pendingChange ?? null
this.service = props.service ?? 'recurly'
this.state = props.state ?? 'active'
this.trialPeriodStart = props.trialPeriodStart ?? null
this.trialPeriodEnd = props.trialPeriodEnd ?? null
this.pausePeriodStart = props.pausePeriodStart ?? null
this.remainingPauseCycles = props.remainingPauseCycles ?? 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 subscription is a group subscription
*
* @return {boolean}
*/
isGroupSubscription() {
return isGroupPlanCode(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 {PaymentProviderSubscriptionChangeRequest}
*/
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 isInTrial = SubscriptionHelper.isInTrial(this.trialPeriodEnd)
const shouldChangeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
currentPlan,
newPlan,
isInTrial
)
const changeRequest = new PaymentProviderSubscriptionChangeRequest({
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 PaymentProviderSubscriptionAddOnUpdate({
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 {PaymentProviderSubscriptionChangeRequest} - 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 PaymentProviderSubscriptionAddOnUpdate({ code, quantity, unitPrice })
)
return new PaymentProviderSubscriptionChangeRequest({
subscription: this,
timeframe: 'now',
addOnUpdates,
})
}
/**
* Update an add-on on this subscription
*
* @param {string} code
* @param {number} quantity
* @return {PaymentProviderSubscriptionChangeRequest} - 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 PaymentProviderSubscriptionChangeRequest({
subscription: this,
timeframe: 'now',
addOnUpdates,
})
}
/**
* Remove an add-on from this subscription
*
* @param {string} code
* @return {PaymentProviderSubscriptionChangeRequest}
*
* @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())
const isInTrial = SubscriptionHelper.isInTrial(this.trialPeriodEnd)
return new PaymentProviderSubscriptionChangeRequest({
subscription: this,
timeframe: isInTrial ? 'now' : 'term_end',
addOnUpdates,
})
}
/**
* Form a request to revert the plan to it's last saved backup state
*
* @param {string} previousPlanCode
* @param {Array<AddOn> | null} previousAddOns
* @return {PaymentProviderSubscriptionChangeRequest}
*
* @throws {OError} if the restore point plan doesnt exist
*/
getRequestForPlanRevert(previousPlanCode, previousAddOns) {
const lastSuccessfulPlan =
PlansLocator.findLocalPlanInSettings(previousPlanCode)
if (lastSuccessfulPlan == null) {
throw new OError('Unable to find plan in settings', { previousPlanCode })
}
const changeRequest = new PaymentProviderSubscriptionChangeRequest({
subscription: this,
timeframe: 'now',
planCode: previousPlanCode,
})
// defaulting to empty array is important, as that will wipe away any add-ons that were added in the failed payment
// but were not part of the last successful subscription
const addOns = []
for (const previousAddon of previousAddOns || []) {
const addOnUpdate = new PaymentProviderSubscriptionAddOnUpdate({
code: previousAddon.addOnCode,
quantity: previousAddon.quantity,
unitPrice: previousAddon.unitAmountInCents / 100,
})
addOns.push(addOnUpdate)
}
changeRequest.addOnUpdates = addOns
return changeRequest
}
/**
* Upgrade group plan with the plan code provided
*
* @param {string} newPlanCode
* @return {PaymentProviderSubscriptionChangeRequest}
*/
getRequestForGroupPlanUpgrade(newPlanCode) {
// Ensure all the existing add-ons are added to the new plan
const addOns = this.addOns.map(
addOn =>
new PaymentProviderSubscriptionAddOnUpdate({
code: addOn.code,
quantity: addOn.quantity,
})
)
return new PaymentProviderSubscriptionChangeRequest({
subscription: this,
timeframe: 'now',
addOnUpdates: addOns,
planCode: newPlanCode,
})
}
/**
* Update the "PO number" and "Terms and conditions" in a subscription
*
* @param {string} poNumber
* @param {string} termsAndConditions
* @return {PaymentProviderSubscriptionUpdateRequest} - the update request to send to
* Recurly
*/
getRequestForPoNumberAndTermsAndConditionsUpdate(
poNumber,
termsAndConditions
) {
return new PaymentProviderSubscriptionUpdateRequest({
subscription: this,
poNumber,
termsAndConditions,
})
}
/**
* Update the "Terms and conditions" in a subscription
*
* @param {string} termsAndConditions
* @return {PaymentProviderSubscriptionUpdateRequest} - the update request to send to
* Recurly
*/
getRequestForTermsAndConditionsUpdate(termsAndConditions) {
return new PaymentProviderSubscriptionUpdateRequest({
subscription: this,
termsAndConditions,
})
}
/**
* Returns whether this subscription is manually collected
*
* @return {boolean}
*/
get isCollectionMethodManual() {
return this.collectionMethod === 'manual'
}
}
/**
* An add-on attached to a subscription
*/
class PaymentProviderSubscriptionAddOn {
/**
* @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 PaymentProviderSubscriptionAddOnUpdate({
code: this.code,
quantity: this.quantity,
unitPrice: this.unitPrice,
})
}
}
class PaymentProviderSubscriptionUpdateRequest {
/**
* @param {object} props
* @param {PaymentProviderSubscription} props.subscription
* @param {string} [props.poNumber]
* @param {string} [props.termsAndConditions]
*/
constructor(props) {
this.subscription = props.subscription
this.poNumber = props.poNumber ?? ''
this.termsAndConditions = props.termsAndConditions ?? ''
}
}
class PaymentProviderSubscriptionChangeRequest {
/**
* @param {object} props
* @param {PaymentProviderSubscription} props.subscription
* @param {"now" | "term_end"} props.timeframe
* @param {string} [props.planCode]
* @param {PaymentProviderSubscriptionAddOnUpdate[]} [props.addOnUpdates]
*/
constructor(props) {
if (props.planCode == null && props.addOnUpdates == null) {
throw new OError('Invalid PaymentProviderSubscriptionChangeRequest', {
props,
})
}
this.subscription = props.subscription
this.timeframe = props.timeframe
this.planCode = props.planCode ?? null
this.addOnUpdates = props.addOnUpdates ?? null
}
}
class PaymentProviderSubscriptionAddOnUpdate {
/**
* @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
this.unitPrice = props.unitPrice ?? null
}
}
class PaymentProviderSubscriptionChange {
/**
* @param {object} props
* @param {PaymentProviderSubscription} props.subscription
* @param {string} props.nextPlanCode
* @param {string} props.nextPlanName
* @param {number} props.nextPlanPrice
* @param {PaymentProviderSubscriptionAddOn[]} props.nextAddOns
* @param {PaymentProviderImmediateCharge} [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 PaymentProviderImmediateCharge({
subtotal: 0,
tax: 0,
total: 0,
discount: 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 PaymentProviderImmediateCharge {
/**
* @param {object} props
* @param {number} props.subtotal
* @param {number} props.tax
* @param {number} props.total
* @param {number} props.discount
*/
constructor(props) {
this.subtotal = props.subtotal
this.tax = props.tax
this.total = props.total
this.discount = props.discount
}
}
/**
* An add-on configuration, independent of any subscription
*/
class PaymentProviderAddOn {
/**
* @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 PaymentProviderPlan {
/**
* @param {object} props
* @param {string} props.code
* @param {string} props.name
*/
constructor(props) {
this.code = props.code
this.name = props.name
}
}
/**
* A coupon in the payment provider
*/
class PaymentProviderCoupon {
/**
* @param {object} props
* @param {string} props.code
* @param {string} props.name
* @param {string} [props.description]
*/
constructor(props) {
this.code = props.code
this.name = props.name
this.description = props.description
}
}
/**
* An account in the payment provider
*/
class PaymentProviderAccount {
/**
* @param {object} props
* @param {string} props.code
* @param {string} props.email
* @param {boolean} props.hasPastDueInvoice
*/
constructor(props) {
this.code = props.code
this.email = props.email
this.hasPastDueInvoice = props.hasPastDueInvoice ?? false
}
}
/**
* Returns whether the given plan code is a group plan
*
* @param {string} planCode
*/
function isGroupPlanCode(planCode) {
return planCode.includes('group')
}
module.exports = {
MEMBERS_LIMIT_ADD_ON_CODE,
PaymentProviderSubscription,
PaymentProviderSubscriptionAddOn,
PaymentProviderSubscriptionChange,
PaymentProviderSubscriptionChangeRequest,
PaymentProviderSubscriptionUpdateRequest,
PaymentProviderSubscriptionAddOnUpdate,
PaypalPaymentMethod,
CreditCardPaymentMethod,
PaymentProviderAddOn,
PaymentProviderPlan,
PaymentProviderCoupon,
PaymentProviderAccount,
isGroupPlanCode,
PaymentProviderImmediateCharge,
}