Merge pull request #21959 from overleaf/em-redeploy-ai-add-on-prs

Redeploy AI add-on PRs

GitOrigin-RevId: d234ac0862947e9ea8926055ad205e32a456dd2e
This commit is contained in:
Eric Mc Sween 2024-11-20 10:19:15 -05:00 committed by Copybot
parent 58e34617bc
commit 6e39885fde
18 changed files with 507 additions and 89 deletions

27
package-lock.json generated
View file

@ -41634,6 +41634,7 @@
"mocha": "^10.2.0", "mocha": "^10.2.0",
"mocha-each": "^2.0.1", "mocha-each": "^2.0.1",
"mock-fs": "^5.1.2", "mock-fs": "^5.1.2",
"nock": "^13.5.6",
"nvd3": "^1.8.6", "nvd3": "^1.8.6",
"overleaf-editor-core": "*", "overleaf-editor-core": "*",
"pdfjs-dist": "4.6.82", "pdfjs-dist": "4.6.82",
@ -43759,6 +43760,20 @@
"@sinonjs/commons": "^1.7.0" "@sinonjs/commons": "^1.7.0"
} }
}, },
"services/web/node_modules/nock": {
"version": "13.5.6",
"resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz",
"integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==",
"dev": true,
"dependencies": {
"debug": "^4.1.0",
"json-stringify-safe": "^5.0.1",
"propagate": "^2.0.0"
},
"engines": {
"node": ">= 10.13"
}
},
"services/web/node_modules/on-finished": { "services/web/node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -51197,6 +51212,7 @@
"mongoose": "8.5.3", "mongoose": "8.5.3",
"multer": "overleaf/multer#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e", "multer": "overleaf/multer#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e",
"nocache": "^2.1.0", "nocache": "^2.1.0",
"nock": "^13.5.6",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"nodemailer": "^6.7.0", "nodemailer": "^6.7.0",
"nodemailer-ses-transport": "^1.5.1", "nodemailer-ses-transport": "^1.5.1",
@ -52767,6 +52783,17 @@
} }
} }
}, },
"nock": {
"version": "13.5.6",
"resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz",
"integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==",
"dev": true,
"requires": {
"debug": "^4.1.0",
"json-stringify-safe": "^5.0.1",
"propagate": "^2.0.0"
}
},
"on-finished": { "on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",

View file

@ -13,6 +13,7 @@ const {
PaypalPaymentMethod, PaypalPaymentMethod,
CreditCardPaymentMethod, CreditCardPaymentMethod,
RecurlyAddOn, RecurlyAddOn,
RecurlyPlan,
} = require('./RecurlyEntities') } = require('./RecurlyEntities')
/** /**
@ -67,6 +68,45 @@ async function getSubscription(subscriptionId) {
return subscriptionFromApi(subscription) return subscriptionFromApi(subscription)
} }
/**
* Get the subscription for a given user
*
* Returns null if the user doesn't have an account or a subscription. Throws an
* error if the user has more than one subscription.
*
* @param {string} userId
* @return {Promise<RecurlySubscription | null>}
*/
async function getSubscriptionForUser(userId) {
try {
const subscriptions = client.listAccountSubscriptions(`code-${userId}`, {
params: { state: 'active', limit: 2 },
})
let result = null
// The async iterator returns a NotFoundError if the account doesn't exist.
for await (const subscription of subscriptions.each()) {
if (result != null) {
throw new OError('User has more than one Recurly subscription', {
userId,
})
}
result = subscription
}
if (result == null) {
return null
}
return subscriptionFromApi(result)
} catch (err) {
if (err instanceof recurly.errors.NotFoundError) {
return null
} else {
throw err
}
}
}
/** /**
* Request a susbcription change from Recurly * Request a susbcription change from Recurly
* *
@ -161,6 +201,17 @@ async function getAddOn(planCode, addOnCode) {
return addOnFromApi(addOn) return addOnFromApi(addOn)
} }
/**
* Get the configuration for a given plan
*
* @param {string} planCode
* @return {Promise<RecurlyPlan>}
*/
async function getPlan(planCode) {
const plan = await client.getPlan(`code-${planCode}`)
return planFromApi(plan)
}
function subscriptionIsCanceledOrExpired(subscription) { function subscriptionIsCanceledOrExpired(subscription) {
const state = subscription?.recurlyStatus?.state const state = subscription?.recurlyStatus?.state
return state === 'canceled' || state === 'expired' return state === 'canceled' || state === 'expired'
@ -169,41 +220,53 @@ function subscriptionIsCanceledOrExpired(subscription) {
/** /**
* Build a RecurlySubscription from Recurly API data * Build a RecurlySubscription from Recurly API data
* *
* @param {recurly.Subscription} subscription * @param {recurly.Subscription} apiSubscription
* @return {RecurlySubscription} * @return {RecurlySubscription}
*/ */
function subscriptionFromApi(subscription) { function subscriptionFromApi(apiSubscription) {
if ( if (
subscription.uuid == null || apiSubscription.uuid == null ||
subscription.plan == null || apiSubscription.plan == null ||
subscription.plan.code == null || apiSubscription.plan.code == null ||
subscription.plan.name == null || apiSubscription.plan.name == null ||
subscription.account == null || apiSubscription.account == null ||
subscription.account.code == null || apiSubscription.account.code == null ||
subscription.unitAmount == null || apiSubscription.unitAmount == null ||
subscription.subtotal == null || apiSubscription.subtotal == null ||
subscription.total == null || apiSubscription.total == null ||
subscription.currency == null || apiSubscription.currency == null ||
subscription.currentPeriodStartedAt == null || apiSubscription.currentPeriodStartedAt == null ||
subscription.currentPeriodEndsAt == null apiSubscription.currentPeriodEndsAt == null
) { ) {
throw new OError('Invalid Recurly subscription', { subscription }) throw new OError('Invalid Recurly subscription', {
} subscription: apiSubscription,
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(subscriptionAddOnFromApi),
subtotal: subscription.subtotal,
taxRate: subscription.taxInfo?.rate ?? 0,
taxAmount: subscription.tax ?? 0,
total: subscription.total,
currency: subscription.currency,
periodStart: subscription.currentPeriodStartedAt,
periodEnd: subscription.currentPeriodEndsAt,
}) })
}
const subscription = new RecurlySubscription({
id: apiSubscription.uuid,
userId: apiSubscription.account.code,
planCode: apiSubscription.plan.code,
planName: apiSubscription.plan.name,
planPrice: apiSubscription.unitAmount,
addOns: (apiSubscription.addOns ?? []).map(subscriptionAddOnFromApi),
subtotal: apiSubscription.subtotal,
taxRate: apiSubscription.taxInfo?.rate ?? 0,
taxAmount: apiSubscription.tax ?? 0,
total: apiSubscription.total,
currency: apiSubscription.currency,
periodStart: apiSubscription.currentPeriodStartedAt,
periodEnd: apiSubscription.currentPeriodEndsAt,
})
if (apiSubscription.pendingChange != null) {
subscription.pendingChange = subscriptionChangeFromApi(
subscription,
apiSubscription.pendingChange
)
}
return subscription
} }
/** /**
@ -251,14 +314,22 @@ function subscriptionChangeFromApi(subscription, subscriptionChange) {
const nextAddOns = (subscriptionChange.addOns ?? []).map( const nextAddOns = (subscriptionChange.addOns ?? []).map(
subscriptionAddOnFromApi subscriptionAddOnFromApi
) )
let immediateCharge =
subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0
for (const creditInvoice of subscriptionChange.invoiceCollection
?.creditInvoices ?? []) {
// The credit invoice totals are already negative
immediateCharge += creditInvoice.total ?? 0
}
return new RecurlySubscriptionChange({ return new RecurlySubscriptionChange({
subscription, subscription,
nextPlanCode: subscriptionChange.plan.code, nextPlanCode: subscriptionChange.plan.code,
nextPlanName: subscriptionChange.plan.name, nextPlanName: subscriptionChange.plan.name,
nextPlanPrice: subscriptionChange.unitAmount, nextPlanPrice: subscriptionChange.unitAmount,
nextAddOns, nextAddOns,
immediateCharge: immediateCharge,
subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0,
}) })
} }
@ -303,6 +374,22 @@ function addOnFromApi(addOn) {
}) })
} }
/**
* Build a RecurlyPlan from Recurly API data
*
* @param {recurly.Plan} plan
* @return {RecurlyPlan}
*/
function planFromApi(plan) {
if (plan.code == null || plan.name == null) {
throw new OError('Invalid Recurly add-on', { plan })
}
return new RecurlyPlan({
code: plan.code,
name: plan.name,
})
}
/** /**
* Build an API request from a RecurlySubscriptionChangeRequest * Build an API request from a RecurlySubscriptionChangeRequest
* *
@ -339,6 +426,7 @@ module.exports = {
getAccountForUserId: callbackify(getAccountForUserId), getAccountForUserId: callbackify(getAccountForUserId),
createAccountForUserId: callbackify(createAccountForUserId), createAccountForUserId: callbackify(createAccountForUserId),
getSubscription: callbackify(getSubscription), getSubscription: callbackify(getSubscription),
getSubscriptionForUser: callbackify(getSubscriptionForUser),
previewSubscriptionChange: callbackify(previewSubscriptionChange), previewSubscriptionChange: callbackify(previewSubscriptionChange),
applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest), applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
removeSubscriptionChange: callbackify(removeSubscriptionChange), removeSubscriptionChange: callbackify(removeSubscriptionChange),
@ -347,10 +435,12 @@ module.exports = {
cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid), cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
getPaymentMethod: callbackify(getPaymentMethod), getPaymentMethod: callbackify(getPaymentMethod),
getAddOn: callbackify(getAddOn), getAddOn: callbackify(getAddOn),
getPlan: callbackify(getPlan),
subscriptionIsCanceledOrExpired, subscriptionIsCanceledOrExpired,
promises: { promises: {
getSubscription, getSubscription,
getSubscriptionForUser,
getAccountForUserId, getAccountForUserId,
createAccountForUserId, createAccountForUserId,
previewSubscriptionChange, previewSubscriptionChange,
@ -361,5 +451,6 @@ module.exports = {
cancelSubscriptionByUuid, cancelSubscriptionByUuid,
getPaymentMethod, getPaymentMethod,
getAddOn, getAddOn,
getPlan,
}, },
} }

View file

@ -6,6 +6,7 @@ const PlansLocator = require('./PlansLocator')
const SubscriptionHelper = require('./SubscriptionHelper') const SubscriptionHelper = require('./SubscriptionHelper')
const AI_ADD_ON_CODE = 'assistant' const AI_ADD_ON_CODE = 'assistant'
const STANDALONE_AI_ADD_ON_CODES = ['assistant', 'assistant-annual']
class RecurlySubscription { class RecurlySubscription {
/** /**
@ -23,6 +24,7 @@ class RecurlySubscription {
* @param {number} props.total * @param {number} props.total
* @param {Date} props.periodStart * @param {Date} props.periodStart
* @param {Date} props.periodEnd * @param {Date} props.periodEnd
* @param {RecurlySubscriptionChange} [props.pendingChange]
*/ */
constructor(props) { constructor(props) {
this.id = props.id this.id = props.id
@ -38,12 +40,47 @@ class RecurlySubscription {
this.total = props.total this.total = props.total
this.periodStart = props.periodStart this.periodStart = props.periodStart
this.periodEnd = props.periodEnd this.periodEnd = props.periodEnd
this.pendingChange = props.pendingChange ?? null
} }
/**
* Returns whether this subscription currently has the given add-on
*
* @param {string} code
* @return {boolean}
*/
hasAddOn(code) { hasAddOn(code) {
return this.addOns.some(addOn => addOn.code === 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 * Change this subscription's plan
* *
@ -60,16 +97,31 @@ class RecurlySubscription {
if (newPlan == null) { if (newPlan == null) {
throw new OError('Unable to find plan in settings', { planCode }) throw new OError('Unable to find plan in settings', { planCode })
} }
const changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd( const shouldChangeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
currentPlan, currentPlan,
newPlan newPlan
) )
const timeframe = changeAtTermEnd ? 'term_end' : 'now'
return new RecurlySubscriptionChangeRequest({ const changeRequest = new RecurlySubscriptionChangeRequest({
subscription: this, subscription: this,
timeframe, timeframe: shouldChangeAtTermEnd ? 'term_end' : 'now',
planCode, 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
} }
/** /**
@ -262,6 +314,30 @@ class RecurlyAddOn {
} }
} }
/**
* 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 = { module.exports = {
AI_ADD_ON_CODE, AI_ADD_ON_CODE,
RecurlySubscription, RecurlySubscription,
@ -272,4 +348,6 @@ module.exports = {
PaypalPaymentMethod, PaypalPaymentMethod,
CreditCardPaymentMethod, CreditCardPaymentMethod,
RecurlyAddOn, RecurlyAddOn,
RecurlyPlan,
isStandaloneAiAddOnPlanCode,
} }

View file

@ -10,6 +10,10 @@ const Errors = require('../Errors/Errors')
const SubscriptionErrors = require('./Errors') const SubscriptionErrors = require('./Errors')
const { callbackify } = require('@overleaf/promise-utils') const { callbackify } = require('@overleaf/promise-utils')
/**
* @param accountId
* @param newEmail
*/
async function updateAccountEmailAddress(accountId, newEmail) { async function updateAccountEmailAddress(accountId, newEmail) {
const data = { const data = {
email: newEmail, email: newEmail,
@ -814,6 +818,9 @@ const promises = {
} }
}, },
/**
* @param xml
*/
_parseXml(xml) { _parseXml(xml) {
function convertDataTypes(data) { function convertDataTypes(data) {
let key, value let key, value

View file

@ -29,9 +29,13 @@ const HttpErrorHandler = require('../Errors/HttpErrorHandler')
const { URLSearchParams } = require('url') const { URLSearchParams } = require('url')
const RecurlyClient = require('./RecurlyClient') const RecurlyClient = require('./RecurlyClient')
const { AI_ADD_ON_CODE } = require('./RecurlyEntities') const { AI_ADD_ON_CODE } = require('./RecurlyEntities')
const PlansLocator = require('./PlansLocator')
/** /**
* @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview'
* @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview' * @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview'
* @import { RecurlySubscriptionChange } from './RecurlyEntities'
* @import { PaymentMethod } from './types'
*/ */
const groupPlanModalOptions = Settings.groupPlanModalOptions const groupPlanModalOptions = Settings.groupPlanModalOptions
@ -516,38 +520,17 @@ async function previewAddonPurchase(req, res) {
) )
/** @type {SubscriptionChangePreview} */ /** @type {SubscriptionChangePreview} */
const changePreview = { const changePreview = makeChangePreview(
change: { {
type: 'add-on-purchase', type: 'add-on-purchase',
addOn: { addOn: {
code: addOn.code, code: addOn.code,
name: addOn.name, name: addOn.name,
}, },
}, },
currency: subscription.currency, subscriptionChange,
immediateCharge: subscriptionChange.immediateCharge, paymentMethod
paymentMethod: paymentMethod.toString(), )
nextInvoice: {
date: subscription.periodEnd.toISOString(),
plan: {
name: subscriptionChange.nextPlanName,
amount: subscriptionChange.nextPlanPrice,
},
addOns: subscriptionChange.nextAddOns.map(addOn => ({
code: addOn.code,
name: addOn.name,
quantity: addOn.quantity,
unitAmount: addOn.unitPrice,
amount: addOn.preTaxTotal,
})),
subtotal: subscriptionChange.subtotal,
tax: {
rate: subscription.taxRate,
amount: subscriptionChange.tax,
},
total: subscriptionChange.total,
},
}
res.render('subscriptions/preview-change', { changePreview }) res.render('subscriptions/preview-change', { changePreview })
} }
@ -619,6 +602,31 @@ async function removeAddon(req, res, next) {
} }
} }
async function previewSubscription(req, res, next) {
const planCode = req.query.planCode
if (!planCode) {
return HttpErrorHandler.notFound(req, res, 'Missing plan code')
}
const plan = await RecurlyClient.promises.getPlan(planCode)
const userId = SessionManager.getLoggedInUserId(req.session)
const subscriptionChange =
await SubscriptionHandler.promises.previewSubscriptionChange(
userId,
planCode
)
const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
const changePreview = makeChangePreview(
{
type: 'premium-subscription',
plan: { code: plan.code, name: plan.name },
},
subscriptionChange,
paymentMethod
)
res.render('subscriptions/preview-change', { changePreview })
}
function updateSubscription(req, res, next) { function updateSubscription(req, res, next) {
const origin = req && req.query ? req.query.origin : null const origin = req && req.query ? req.query.origin : null
const user = SessionManager.getSessionUser(req.session) const user = SessionManager.getSessionUser(req.session)
@ -887,6 +895,54 @@ async function getLatamCountryBannerDetails(req, res) {
return latamCountryBannerDetails return latamCountryBannerDetails
} }
/**
* Build a subscription change preview for display purposes
*
* @param {SubscriptionChangeDescription} subscriptionChangeDescription A description of the change for the frontend
* @param {RecurlySubscriptionChange} subscriptionChange The subscription change object coming from Recurly
* @param {PaymentMethod} paymentMethod The payment method associated to the user
* @return {SubscriptionChangePreview}
*/
function makeChangePreview(
subscriptionChangeDescription,
subscriptionChange,
paymentMethod
) {
const subscription = subscriptionChange.subscription
const nextPlan = PlansLocator.findLocalPlanInSettings(
subscriptionChange.nextPlanCode
)
return {
change: subscriptionChangeDescription,
currency: subscription.currency,
immediateCharge: subscriptionChange.immediateCharge,
paymentMethod: paymentMethod.toString(),
nextPlan: {
annual: nextPlan.annual ?? false,
},
nextInvoice: {
date: subscription.periodEnd.toISOString(),
plan: {
name: subscriptionChange.nextPlanName,
amount: subscriptionChange.nextPlanPrice,
},
addOns: subscriptionChange.nextAddOns.map(addOn => ({
code: addOn.code,
name: addOn.name,
quantity: addOn.quantity,
unitAmount: addOn.unitPrice,
amount: addOn.preTaxTotal,
})),
subtotal: subscriptionChange.subtotal,
tax: {
rate: subscription.taxRate,
amount: subscriptionChange.tax,
},
total: subscriptionChange.total,
},
}
}
module.exports = { module.exports = {
plansPage: expressify(plansPage), plansPage: expressify(plansPage),
plansPageLightDesign: expressify(plansPageLightDesign), plansPageLightDesign: expressify(plansPageLightDesign),
@ -896,6 +952,7 @@ module.exports = {
cancelSubscription, cancelSubscription,
canceledSubscription: expressify(canceledSubscription), canceledSubscription: expressify(canceledSubscription),
cancelV1Subscription, cancelV1Subscription,
previewSubscription: expressify(previewSubscription),
updateSubscription, updateSubscription,
cancelPendingSubscriptionChange, cancelPendingSubscriptionChange,
updateAccountEmailAddress, updateAccountEmailAddress,

View file

@ -65,6 +65,26 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) {
) )
} }
/**
* Preview the effect of changing the subscription plan
*
* @param {string} userId
* @param {string} planCode
* @return {Promise<RecurlySubscriptionChange>}
*/
async function previewSubscriptionChange(userId, planCode) {
const recurlyId = await getSubscriptionRecurlyId(userId)
if (recurlyId == null) {
throw new NoRecurlySubscriptionError('Subscription not found', { userId })
}
const subscription = await RecurlyClient.promises.getSubscription(recurlyId)
const changeRequest = subscription?.getRequestForPlanChange(planCode)
const change =
await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
return change
}
/** /**
* @param user * @param user
* @param planCode * @param planCode
@ -361,6 +381,7 @@ async function getSubscriptionRecurlyId(userId) {
module.exports = { module.exports = {
validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly), validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
createSubscription: callbackify(createSubscription), createSubscription: callbackify(createSubscription),
previewSubscriptionChange: callbackify(previewSubscriptionChange),
updateSubscription: callbackify(updateSubscription), updateSubscription: callbackify(updateSubscription),
cancelPendingSubscriptionChange: callbackify(cancelPendingSubscriptionChange), cancelPendingSubscriptionChange: callbackify(cancelPendingSubscriptionChange),
cancelSubscription: callbackify(cancelSubscription), cancelSubscription: callbackify(cancelSubscription),
@ -374,6 +395,7 @@ module.exports = {
promises: { promises: {
validateNoSubscriptionInRecurly, validateNoSubscriptionInRecurly,
createSubscription, createSubscription,
previewSubscriptionChange,
updateSubscription, updateSubscription,
cancelPendingSubscriptionChange, cancelPendingSubscriptionChange,
cancelSubscription, cancelSubscription,

View file

@ -121,6 +121,12 @@ export default {
) )
// user changes their account state // user changes their account state
webRouter.get(
'/user/subscription/preview',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
SubscriptionController.previewSubscription
)
webRouter.post( webRouter.post(
'/user/subscription/update', '/user/subscription/update',
AuthenticationController.requireLogin(), AuthenticationController.requireLogin(),

View file

@ -67,6 +67,7 @@
"add_company_details_lowercase": "", "add_company_details_lowercase": "",
"add_email_address": "", "add_email_address": "",
"add_email_to_claim_features": "", "add_email_to_claim_features": "",
"add_error_assist_to_your_projects": "",
"add_files": "", "add_files": "",
"add_more_collaborators": "", "add_more_collaborators": "",
"add_more_editors": "", "add_more_editors": "",
@ -1482,6 +1483,7 @@
"submit_title": "", "submit_title": "",
"subscribe": "", "subscribe": "",
"subscribe_to_find_the_symbols_you_need_faster": "", "subscribe_to_find_the_symbols_you_need_faster": "",
"subscribe_to_plan": "",
"subscription": "", "subscription": "",
"subscription_admins_cannot_be_deleted": "", "subscription_admins_cannot_be_deleted": "",
"subscription_canceled": "", "subscription_canceled": "",

View file

@ -7,11 +7,7 @@ import { ExpiredSubscription } from './states/expired'
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email' import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email'
import OLNotification from '@/features/ui/components/ol/ol-notification' import OLNotification from '@/features/ui/components/ol/ol-notification'
import { import { isStandaloneAiPlanCode, AI_ADD_ON_CODE } from '../../data/add-on-codes'
AI_STANDALONE_PLAN_CODE,
AI_STANDALONE_ANNUAL_PLAN_CODE,
AI_ADD_ON_CODE,
} from '../../data/add-on-codes'
function PastDueSubscriptionAlert({ function PastDueSubscriptionAlert({
subscription, subscription,
@ -50,11 +46,7 @@ function PersonalSubscriptionStates({
addOn => addOn.addOnCode === AI_ADD_ON_CODE addOn => addOn.addOnCode === AI_ADD_ON_CODE
) )
const onAiStandalonePlan = [ const onAiStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
AI_STANDALONE_PLAN_CODE,
AI_STANDALONE_ANNUAL_PLAN_CODE,
].includes(subscription.planCode)
const planHasAi = onAiStandalonePlan || hasAiAddon const planHasAi = onAiStandalonePlan || hasAiAddon
if (state === 'active' && planHasAi) { if (state === 'active' && planHasAi) {

View file

@ -15,8 +15,7 @@ import {
ADD_ON_NAME, ADD_ON_NAME,
AI_ADD_ON_CODE, AI_ADD_ON_CODE,
AI_STANDALONE_PLAN_NAME, AI_STANDALONE_PLAN_NAME,
AI_STANDALONE_PLAN_CODE, isStandaloneAiPlanCode,
AI_STANDALONE_ANNUAL_PLAN_CODE,
} from '../../../../data/add-on-codes' } from '../../../../data/add-on-codes'
import { CancelSubscriptionButton } from './cancel-subscription-button' import { CancelSubscriptionButton } from './cancel-subscription-button'
@ -32,10 +31,7 @@ export function ActiveAiAddonSubscription({
useSubscriptionDashboardContext() useSubscriptionDashboardContext()
if (showCancellation) return <CancelSubscription /> if (showCancellation) return <CancelSubscription />
const onStandalonePlan = [ const onStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
AI_STANDALONE_PLAN_CODE,
AI_STANDALONE_ANNUAL_PLAN_CODE,
].includes(subscription.planCode)
const handlePlanChange = () => setModalIdShown('change-plan') const handlePlanChange = () => setModalIdShown('change-plan')

View file

@ -14,8 +14,8 @@ import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification' import OLNotification from '@/features/ui/components/ol/ol-notification'
import { import {
AI_ADD_ON_CODE, AI_ADD_ON_CODE,
AI_STANDALONE_PLAN_CODE,
ADD_ON_NAME, ADD_ON_NAME,
isStandaloneAiPlanCode,
} from '../../../../../../data/add-on-codes' } from '../../../../../../data/add-on-codes'
import { import {
cancelSubscriptionUrl, cancelSubscriptionUrl,
@ -33,7 +33,7 @@ export function CancelAiAddOnModal() {
if (!personalSubscription) return null if (!personalSubscription) return null
const onStandalone = personalSubscription.planCode === AI_STANDALONE_PLAN_CODE const onStandalone = isStandaloneAiPlanCode(personalSubscription.planCode)
const cancellationEndpoint = onStandalone const cancellationEndpoint = onStandalone
? cancelSubscriptionUrl ? cancelSubscriptionUrl

View file

@ -31,13 +31,17 @@ function PreviewSubscriptionChange() {
<Row> <Row>
<Col md={8} mdOffset={2}> <Col md={8} mdOffset={2}>
<div className="card p-5"> <div className="card p-5">
{preview.change.type === 'add-on-purchase' && ( {preview.change.type === 'add-on-purchase' ? (
<h1> <h1>
{t('add_add_on_to_your_plan', { {t('add_add_on_to_your_plan', {
addOnName: preview.change.addOn.name, addOnName: preview.change.addOn.name,
})} })}
</h1> </h1>
)} ) : preview.change.type === 'premium-subscription' ? (
<h1>
{t('subscribe_to_plan', { planName: preview.change.plan.name })}
</h1>
) : null}
{payNowTask.isError && ( {payNowTask.isError && (
<Notification <Notification
@ -111,7 +115,11 @@ function PreviewSubscriptionChange() {
)} )}
<Row className="mt-1"> <Row className="mt-1">
<Col xs={9}>{t('total_per_month')}</Col> <Col xs={9}>
{preview.nextPlan.annual
? t('total_per_year')
: t('total_per_month')}
</Col>
<Col xs={3} className="text-right"> <Col xs={3} className="text-right">
{formatCurrencyLocalized( {formatCurrencyLocalized(
preview.nextInvoice.total, preview.nextInvoice.total,

View file

@ -4,3 +4,7 @@ export const AI_ADD_ON_CODE = 'assistant'
export const ADD_ON_NAME = "Error Assist" export const ADD_ON_NAME = "Error Assist"
export const AI_STANDALONE_PLAN_NAME = "Overleaf Free" export const AI_STANDALONE_PLAN_NAME = "Overleaf Free"
export const AI_STANDALONE_ANNUAL_PLAN_CODE = 'assistant-annual' export const AI_STANDALONE_ANNUAL_PLAN_CODE = 'assistant-annual'
export function isStandaloneAiPlanCode(planCode: string) {
return planCode === AI_STANDALONE_PLAN_CODE || planCode === AI_STANDALONE_ANNUAL_PLAN_CODE
}

View file

@ -79,6 +79,7 @@
"add_email": "Add Email", "add_email": "Add Email",
"add_email_address": "Add email address", "add_email_address": "Add email address",
"add_email_to_claim_features": "Add an institutional email address to claim your features.", "add_email_to_claim_features": "Add an institutional email address to claim your features.",
"add_error_assist_to_your_projects": "Add Error Assist to your projects and get AI help to fix LaTeX errors faster.",
"add_files": "Add Files", "add_files": "Add Files",
"add_more_collaborators": "Add more collaborators", "add_more_collaborators": "Add more collaborators",
"add_more_editors": "Add more editors", "add_more_editors": "Add more editors",
@ -2059,6 +2060,7 @@
"submit_title": "Submit", "submit_title": "Submit",
"subscribe": "Subscribe", "subscribe": "Subscribe",
"subscribe_to_find_the_symbols_you_need_faster": "Subscribe to find the symbols you need faster", "subscribe_to_find_the_symbols_you_need_faster": "Subscribe to find the symbols you need faster",
"subscribe_to_plan": "Subscribe to __planName__",
"subscription": "Subscription", "subscription": "Subscription",
"subscription_admin_panel": "admin panel", "subscription_admin_panel": "admin panel",
"subscription_admins_cannot_be_deleted": "You cannot delete your account while on a subscription. Please cancel your subscription and try again. If you keep seeing this message please contact us.", "subscription_admins_cannot_be_deleted": "You cannot delete your account while on a subscription. Please cancel your subscription and try again. If you keep seeing this message please contact us.",

View file

@ -234,7 +234,6 @@
"@types/chai": "^4.3.0", "@types/chai": "^4.3.0",
"@types/dateformat": "^5.0.2", "@types/dateformat": "^5.0.2",
"@types/diff": "^5.0.9", "@types/diff": "^5.0.9",
"uuid": "^9.0.1",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/events": "^3.0.0", "@types/events": "^3.0.0",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
@ -258,8 +257,8 @@
"@uppy/react": "^3.2.1", "@uppy/react": "^3.2.1",
"@uppy/utils": "^5.7.0", "@uppy/utils": "^5.7.0",
"@uppy/xhr-upload": "^3.6.0", "@uppy/xhr-upload": "^3.6.0",
"abort-controller": "^3.0.0",
"5to6-codemod": "^1.8.0", "5to6-codemod": "^1.8.0",
"abort-controller": "^3.0.0",
"acorn": "^7.1.1", "acorn": "^7.1.1",
"acorn-walk": "^7.1.1", "acorn-walk": "^7.1.1",
"algoliasearch": "^3.35.1", "algoliasearch": "^3.35.1",
@ -323,6 +322,7 @@
"mocha": "^10.2.0", "mocha": "^10.2.0",
"mocha-each": "^2.0.1", "mocha-each": "^2.0.1",
"mock-fs": "^5.1.2", "mock-fs": "^5.1.2",
"nock": "^13.5.6",
"nvd3": "^1.8.6", "nvd3": "^1.8.6",
"overleaf-editor-core": "*", "overleaf-editor-core": "*",
"pdfjs-dist": "4.6.82", "pdfjs-dist": "4.6.82",
@ -363,6 +363,7 @@
"to-string-loader": "^1.2.0", "to-string-loader": "^1.2.0",
"tty-browserify": "^0.0.1", "tty-browserify": "^0.0.1",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"uuid": "^9.0.1",
"w3c-keyname": "^2.2.8", "w3c-keyname": "^2.2.8",
"webpack": "^5.93.0", "webpack": "^5.93.0",
"webpack-assets-manifest": "^5.2.1", "webpack-assets-manifest": "^5.2.1",

View file

@ -3,6 +3,7 @@ const { expect } = require('chai')
const recurly = require('recurly') const recurly = require('recurly')
const SandboxedModule = require('sandboxed-module') const SandboxedModule = require('sandboxed-module')
const { const {
RecurlySubscription,
RecurlySubscriptionChangeRequest, RecurlySubscriptionChangeRequest,
RecurlySubscriptionAddOnUpdate, RecurlySubscriptionAddOnUpdate,
} = require('../../../../app/src/Features/Subscription/RecurlyEntities') } = require('../../../../app/src/Features/Subscription/RecurlyEntities')
@ -35,7 +36,7 @@ describe('RecurlyClient', function () {
preTaxTotal: 2, preTaxTotal: 2,
} }
this.subscription = { this.subscription = new RecurlySubscription({
id: 'subscription-id', id: 'subscription-id',
userId: 'user-id', userId: 'user-id',
currency: 'EUR', currency: 'EUR',
@ -49,7 +50,7 @@ describe('RecurlyClient', function () {
total: 16.5, total: 16.5,
periodStart: new Date(), periodStart: new Date(),
periodEnd: new Date(), periodEnd: new Date(),
} })
this.recurlySubscription = { this.recurlySubscription = {
uuid: this.subscription.id, uuid: this.subscription.id,
@ -96,6 +97,7 @@ describe('RecurlyClient', function () {
let client let client
this.client = client = { this.client = client = {
getAccount: sinon.stub(), getAccount: sinon.stub(),
listAccountSubscriptions: sinon.stub(),
} }
this.recurly = { this.recurly = {
errors: recurly.errors, errors: recurly.errors,
@ -198,6 +200,54 @@ describe('RecurlyClient', function () {
}) })
}) })
describe('getSubscriptionForUser', function () {
it("should return null if the account doesn't exist", async function () {
this.client.listAccountSubscriptions.returns({
// eslint-disable-next-line require-yield
each: async function* () {
throw new recurly.errors.NotFoundError('account not found')
},
})
const subscription =
await this.RecurlyClient.promises.getSubscriptionForUser('some-user')
expect(subscription).to.be.null
})
it("should return null if the account doesn't have subscriptions", async function () {
this.client.listAccountSubscriptions.returns({
each: async function* () {},
})
const subscription =
await this.RecurlyClient.promises.getSubscriptionForUser('some-user')
expect(subscription).to.be.null
})
it('should return the subscription if the account has one subscription', async function () {
const recurlySubscription = this.recurlySubscription
this.client.listAccountSubscriptions.returns({
each: async function* () {
yield recurlySubscription
},
})
const subscription =
await this.RecurlyClient.promises.getSubscriptionForUser('some-user')
expect(subscription).to.deep.equal(this.subscription)
})
it('should throw an error if the account has more than one subscription', async function () {
const recurlySubscription = this.recurlySubscription
this.client.listAccountSubscriptions.returns({
each: async function* () {
yield recurlySubscription
yield { another: 'subscription' }
},
})
await expect(
this.RecurlyClient.promises.getSubscriptionForUser('some-user')
).to.be.rejected
})
})
describe('applySubscriptionChangeRequest', function () { describe('applySubscriptionChangeRequest', function () {
beforeEach(function () { beforeEach(function () {
this.client.createSubscriptionChange = sinon this.client.createSubscriptionChange = sinon

View file

@ -4,9 +4,11 @@ const SandboxedModule = require('sandboxed-module')
const { expect } = require('chai') const { expect } = require('chai')
const Errors = require('../../../../app/src/Features/Subscription/Errors') const Errors = require('../../../../app/src/Features/Subscription/Errors')
const { const {
AI_ADD_ON_CODE,
RecurlySubscriptionChangeRequest, RecurlySubscriptionChangeRequest,
RecurlySubscriptionChange, RecurlySubscriptionChange,
RecurlySubscription, RecurlySubscription,
RecurlySubscriptionAddOnUpdate,
} = require('../../../../app/src/Features/Subscription/RecurlyEntities') } = require('../../../../app/src/Features/Subscription/RecurlyEntities')
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities' const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
@ -16,6 +18,7 @@ describe('RecurlyEntities', function () {
beforeEach(function () { beforeEach(function () {
this.Settings = { this.Settings = {
plans: [ plans: [
{ planCode: 'assistant-annual', price_in_cents: 5900 },
{ planCode: 'cheap-plan', price_in_cents: 500 }, { planCode: 'cheap-plan', price_in_cents: 500 },
{ planCode: 'regular-plan', price_in_cents: 1000 }, { planCode: 'regular-plan', price_in_cents: 1000 },
{ planCode: 'premium-plan', price_in_cents: 2000 }, { planCode: 'premium-plan', price_in_cents: 2000 },
@ -92,6 +95,67 @@ describe('RecurlyEntities', function () {
}) })
) )
}) })
it('preserves the AI add-on on upgrades', function () {
const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
this.addOn.code = AI_ADD_ON_CODE
const changeRequest =
this.subscription.getRequestForPlanChange('premium-plan')
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'premium-plan',
addOnUpdates: [
new RecurlySubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
}),
],
})
)
})
it('preserves the AI add-on on downgrades', function () {
const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
this.addOn.code = AI_ADD_ON_CODE
const changeRequest =
this.subscription.getRequestForPlanChange('cheap-plan')
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'term_end',
planCode: 'cheap-plan',
addOnUpdates: [
new RecurlySubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
}),
],
})
)
})
it('preserves the AI add-on on upgrades from the standalone AI plan', function () {
const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
this.subscription.planCode = 'assistant-annual'
this.subscription.addOns = []
const changeRequest =
this.subscription.getRequestForPlanChange('cheap-plan')
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'term_end',
planCode: 'cheap-plan',
addOnUpdates: [
new RecurlySubscriptionAddOnUpdate({
code: AI_ADD_ON_CODE,
quantity: 1,
}),
],
})
)
})
}) })
describe('getRequestForAddOnPurchase()', function () { describe('getRequestForAddOnPurchase()', function () {

View file

@ -1,8 +1,11 @@
export type SubscriptionChangePreview = { export type SubscriptionChangePreview = {
change: SubscriptionChange change: SubscriptionChangeDescription
currency: string currency: string
paymentMethod: string paymentMethod: string
immediateCharge: number immediateCharge: number
nextPlan: {
annual: boolean
}
nextInvoice: { nextInvoice: {
date: string date: string
plan: { plan: {
@ -27,7 +30,7 @@ type AddOn = {
amount: number amount: number
} }
type SubscriptionChange = AddOnPurchase export type SubscriptionChangeDescription = AddOnPurchase | PremiumSubscription
type AddOnPurchase = { type AddOnPurchase = {
type: 'add-on-purchase' type: 'add-on-purchase'
@ -36,3 +39,11 @@ type AddOnPurchase = {
name: string name: string
} }
} }
type PremiumSubscription = {
type: 'premium-subscription'
plan: {
code: string
name: string
}
}