diff --git a/package-lock.json b/package-lock.json index e16ad3b12d..a17927f768 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41611,6 +41611,7 @@ "mocha": "^10.2.0", "mocha-each": "^2.0.1", "mock-fs": "^5.1.2", + "nock": "^13.5.6", "nvd3": "^1.8.6", "overleaf-editor-core": "*", "pdfjs-dist": "4.6.82", @@ -43736,6 +43737,20 @@ "@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": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -51174,6 +51189,7 @@ "mongoose": "8.5.3", "multer": "overleaf/multer#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e", "nocache": "^2.1.0", + "nock": "^13.5.6", "node-fetch": "^2.7.0", "nodemailer": "^6.7.0", "nodemailer-ses-transport": "^1.5.1", @@ -52744,6 +52760,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": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index 44a5c5600e..5c3f464f68 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -13,6 +13,7 @@ const { PaypalPaymentMethod, CreditCardPaymentMethod, RecurlyAddOn, + RecurlyPlan, } = require('./RecurlyEntities') /** @@ -67,6 +68,32 @@ async function getSubscription(subscriptionId) { return subscriptionFromApi(subscription) } +/** + * Get the subscription for a given user + * + * @param {string} userId + * @return {Promise} + */ +async function getSubscriptionForUser(userId) { + const subscriptions = await client.listAccountSubscriptions( + `code-${userId}`, + { params: { state: 'active', limit: 2 } } + ) + let result = null + 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) +} + /** * Request a susbcription change from Recurly * @@ -161,6 +188,17 @@ async function getAddOn(planCode, addOnCode) { return addOnFromApi(addOn) } +/** + * Get the configuration for a given plan + * + * @param {string} planCode + * @return {Promise} + */ +async function getPlan(planCode) { + const plan = await client.getPlan(`code-${planCode}`) + return planFromApi(plan) +} + function subscriptionIsCanceledOrExpired(subscription) { const state = subscription?.recurlyStatus?.state return state === 'canceled' || state === 'expired' @@ -303,6 +341,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 * @@ -339,6 +393,7 @@ module.exports = { getAccountForUserId: callbackify(getAccountForUserId), createAccountForUserId: callbackify(createAccountForUserId), getSubscription: callbackify(getSubscription), + getSubscriptionForUser: callbackify(getSubscriptionForUser), previewSubscriptionChange: callbackify(previewSubscriptionChange), applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest), removeSubscriptionChange: callbackify(removeSubscriptionChange), @@ -347,10 +402,12 @@ module.exports = { cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid), getPaymentMethod: callbackify(getPaymentMethod), getAddOn: callbackify(getAddOn), + getPlan: callbackify(getPlan), subscriptionIsCanceledOrExpired, promises: { getSubscription, + getSubscriptionForUser, getAccountForUserId, createAccountForUserId, previewSubscriptionChange, @@ -361,5 +418,6 @@ module.exports = { cancelSubscriptionByUuid, getPaymentMethod, getAddOn, + getPlan, }, } diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/RecurlyEntities.js index c0f8eb944e..263442c510 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEntities.js +++ b/services/web/app/src/Features/Subscription/RecurlyEntities.js @@ -6,6 +6,7 @@ const PlansLocator = require('./PlansLocator') const SubscriptionHelper = require('./SubscriptionHelper') const AI_ADD_ON_CODE = 'assistant' +const STANDALONE_AI_ADD_ON_CODES = ['assistant', 'assistant-annual'] class RecurlySubscription { /** @@ -44,6 +45,15 @@ class RecurlySubscription { 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) + } + /** * Change this subscription's plan * @@ -262,6 +272,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 = { AI_ADD_ON_CODE, RecurlySubscription, @@ -272,4 +306,6 @@ module.exports = { PaypalPaymentMethod, CreditCardPaymentMethod, RecurlyAddOn, + RecurlyPlan, + isStandaloneAiAddOnPlanCode, } diff --git a/services/web/app/src/Features/Subscription/RecurlyWrapper.js b/services/web/app/src/Features/Subscription/RecurlyWrapper.js index 3cd2d9ad0c..9660a8c5dd 100644 --- a/services/web/app/src/Features/Subscription/RecurlyWrapper.js +++ b/services/web/app/src/Features/Subscription/RecurlyWrapper.js @@ -10,6 +10,10 @@ const Errors = require('../Errors/Errors') const SubscriptionErrors = require('./Errors') const { callbackify } = require('@overleaf/promise-utils') +/** + * @param accountId + * @param newEmail + */ async function updateAccountEmailAddress(accountId, newEmail) { const data = { email: newEmail, @@ -814,6 +818,9 @@ const promises = { } }, + /** + * @param xml + */ _parseXml(xml) { function convertDataTypes(data) { let key, value diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 6982bea794..04bd425084 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -31,7 +31,10 @@ const RecurlyClient = require('./RecurlyClient') const { AI_ADD_ON_CODE } = require('./RecurlyEntities') /** + * @import { SubscriptionChangeDescription } 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 @@ -516,38 +519,17 @@ async function previewAddonPurchase(req, res) { ) /** @type {SubscriptionChangePreview} */ - const changePreview = { - change: { + const changePreview = makeChangePreview( + { type: 'add-on-purchase', addOn: { code: addOn.code, name: addOn.name, }, }, - currency: subscription.currency, - immediateCharge: subscriptionChange.immediateCharge, - 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, - }, - } + subscriptionChange, + paymentMethod + ) res.render('subscriptions/preview-change', { changePreview }) } @@ -619,6 +601,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) { const origin = req && req.query ? req.query.origin : null const user = SessionManager.getSessionUser(req.session) @@ -887,6 +894,48 @@ async function getLatamCountryBannerDetails(req, res) { 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 + return { + change: subscriptionChangeDescription, + currency: subscription.currency, + immediateCharge: subscriptionChange.immediateCharge, + 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, + }, + } +} + module.exports = { plansPage: expressify(plansPage), plansPageLightDesign: expressify(plansPageLightDesign), @@ -896,6 +945,7 @@ module.exports = { cancelSubscription, canceledSubscription: expressify(canceledSubscription), cancelV1Subscription, + previewSubscription: expressify(previewSubscription), updateSubscription, cancelPendingSubscriptionChange, updateAccountEmailAddress, diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 9592c96412..bd1a1476bb 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -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} + */ +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 planCode @@ -361,6 +381,7 @@ async function getSubscriptionRecurlyId(userId) { module.exports = { validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly), createSubscription: callbackify(createSubscription), + previewSubscriptionChange: callbackify(previewSubscriptionChange), updateSubscription: callbackify(updateSubscription), cancelPendingSubscriptionChange: callbackify(cancelPendingSubscriptionChange), cancelSubscription: callbackify(cancelSubscription), @@ -374,6 +395,7 @@ module.exports = { promises: { validateNoSubscriptionInRecurly, createSubscription, + previewSubscriptionChange, updateSubscription, cancelPendingSubscriptionChange, cancelSubscription, diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 2305dc29de..3e7a585509 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -121,6 +121,12 @@ export default { ) // user changes their account state + webRouter.get( + '/user/subscription/preview', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(subscriptionRateLimiter), + SubscriptionController.previewSubscription + ) webRouter.post( '/user/subscription/update', AuthenticationController.requireLogin(), diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index fc805f5058..fd93dae6e6 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1485,6 +1485,7 @@ "submit_title": "", "subscribe": "", "subscribe_to_find_the_symbols_you_need_faster": "", + "subscribe_to_plan": "", "subscription": "", "subscription_admins_cannot_be_deleted": "", "subscription_canceled": "", diff --git a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx b/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx index 09a5824f96..bf625b44ed 100644 --- a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx +++ b/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx @@ -31,13 +31,17 @@ function PreviewSubscriptionChange() {
- {preview.change.type === 'add-on-purchase' && ( + {preview.change.type === 'add-on-purchase' ? (

{t('add_add_on_to_your_plan', { addOnName: preview.change.addOn.name, })}

- )} + ) : preview.change.type === 'premium-subscription' ? ( +

+ {t('subscribe_to_plan', { planName: preview.change.plan.name })} +

+ ) : null} {payNowTask.isError && (