diff --git a/package-lock.json b/package-lock.json index 28963bd560..ad647918aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41634,6 +41634,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", @@ -43759,6 +43760,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", @@ -51197,6 +51212,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", @@ -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": { "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..b7ee96b1c4 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,45 @@ async function getSubscription(subscriptionId) { 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} + */ +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 * @@ -161,6 +201,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' @@ -169,41 +220,53 @@ function subscriptionIsCanceledOrExpired(subscription) { /** * Build a RecurlySubscription from Recurly API data * - * @param {recurly.Subscription} subscription + * @param {recurly.Subscription} apiSubscription * @return {RecurlySubscription} */ -function subscriptionFromApi(subscription) { +function subscriptionFromApi(apiSubscription) { 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 || - subscription.currentPeriodStartedAt == null || - subscription.currentPeriodEndsAt == null + apiSubscription.uuid == null || + apiSubscription.plan == null || + apiSubscription.plan.code == null || + apiSubscription.plan.name == null || + apiSubscription.account == null || + apiSubscription.account.code == null || + apiSubscription.unitAmount == null || + apiSubscription.subtotal == null || + apiSubscription.total == null || + apiSubscription.currency == null || + apiSubscription.currentPeriodStartedAt == 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( 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({ subscription, nextPlanCode: subscriptionChange.plan.code, nextPlanName: subscriptionChange.plan.name, nextPlanPrice: subscriptionChange.unitAmount, nextAddOns, - immediateCharge: - subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0, + immediateCharge, }) } @@ -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 * @@ -339,6 +426,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 +435,12 @@ module.exports = { cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid), getPaymentMethod: callbackify(getPaymentMethod), getAddOn: callbackify(getAddOn), + getPlan: callbackify(getPlan), subscriptionIsCanceledOrExpired, promises: { getSubscription, + getSubscriptionForUser, getAccountForUserId, createAccountForUserId, previewSubscriptionChange, @@ -361,5 +451,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..a80db92321 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 { /** @@ -23,6 +24,7 @@ class RecurlySubscription { * @param {number} props.total * @param {Date} props.periodStart * @param {Date} props.periodEnd + * @param {RecurlySubscriptionChange} [props.pendingChange] */ constructor(props) { this.id = props.id @@ -38,12 +40,47 @@ class RecurlySubscription { this.total = props.total this.periodStart = props.periodStart 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) { 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 * @@ -60,16 +97,31 @@ class RecurlySubscription { if (newPlan == null) { throw new OError('Unable to find plan in settings', { planCode }) } - const changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd( + const shouldChangeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd( currentPlan, newPlan ) - const timeframe = changeAtTermEnd ? 'term_end' : 'now' - return new RecurlySubscriptionChangeRequest({ + + const changeRequest = new RecurlySubscriptionChangeRequest({ subscription: this, - timeframe, + 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 } /** @@ -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 = { AI_ADD_ON_CODE, RecurlySubscription, @@ -272,4 +348,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..4f6e3d4287 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -29,9 +29,13 @@ const HttpErrorHandler = require('../Errors/HttpErrorHandler') const { URLSearchParams } = require('url') const RecurlyClient = require('./RecurlyClient') 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 { RecurlySubscriptionChange } from './RecurlyEntities' + * @import { PaymentMethod } from './types' */ const groupPlanModalOptions = Settings.groupPlanModalOptions @@ -516,38 +520,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 +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) { const origin = req && req.query ? req.query.origin : null const user = SessionManager.getSessionUser(req.session) @@ -887,6 +895,54 @@ 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 + 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 = { plansPage: expressify(plansPage), plansPageLightDesign: expressify(plansPageLightDesign), @@ -896,6 +952,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 b96e66e6bd..79773fd083 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -67,6 +67,7 @@ "add_company_details_lowercase": "", "add_email_address": "", "add_email_to_claim_features": "", + "add_error_assist_to_your_projects": "", "add_files": "", "add_more_collaborators": "", "add_more_editors": "", @@ -1482,6 +1483,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/dashboard/personal-subscription.tsx b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx index 90ed5f6ab2..11da69fc7a 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx @@ -7,11 +7,7 @@ import { ExpiredSubscription } from './states/expired' import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context' import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email' import OLNotification from '@/features/ui/components/ol/ol-notification' -import { - AI_STANDALONE_PLAN_CODE, - AI_STANDALONE_ANNUAL_PLAN_CODE, - AI_ADD_ON_CODE, -} from '../../data/add-on-codes' +import { isStandaloneAiPlanCode, AI_ADD_ON_CODE } from '../../data/add-on-codes' function PastDueSubscriptionAlert({ subscription, @@ -50,11 +46,7 @@ function PersonalSubscriptionStates({ addOn => addOn.addOnCode === AI_ADD_ON_CODE ) - const onAiStandalonePlan = [ - AI_STANDALONE_PLAN_CODE, - AI_STANDALONE_ANNUAL_PLAN_CODE, - ].includes(subscription.planCode) - + const onAiStandalonePlan = isStandaloneAiPlanCode(subscription.planCode) const planHasAi = onAiStandalonePlan || hasAiAddon if (state === 'active' && planHasAi) { diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-ai-addon.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-ai-addon.tsx index c44a9fe656..b257ca2754 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-ai-addon.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-ai-addon.tsx @@ -15,8 +15,7 @@ import { ADD_ON_NAME, AI_ADD_ON_CODE, AI_STANDALONE_PLAN_NAME, - AI_STANDALONE_PLAN_CODE, - AI_STANDALONE_ANNUAL_PLAN_CODE, + isStandaloneAiPlanCode, } from '../../../../data/add-on-codes' import { CancelSubscriptionButton } from './cancel-subscription-button' @@ -32,10 +31,7 @@ export function ActiveAiAddonSubscription({ useSubscriptionDashboardContext() if (showCancellation) return - const onStandalonePlan = [ - AI_STANDALONE_PLAN_CODE, - AI_STANDALONE_ANNUAL_PLAN_CODE, - ].includes(subscription.planCode) + const onStandalonePlan = isStandaloneAiPlanCode(subscription.planCode) const handlePlanChange = () => setModalIdShown('change-plan') diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal.tsx index 34a8ccae99..0eeb139c8b 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/cancel-ai-add-on-modal.tsx @@ -14,8 +14,8 @@ import OLButton from '@/features/ui/components/ol/ol-button' import OLNotification from '@/features/ui/components/ol/ol-notification' import { AI_ADD_ON_CODE, - AI_STANDALONE_PLAN_CODE, ADD_ON_NAME, + isStandaloneAiPlanCode, } from '../../../../../../data/add-on-codes' import { cancelSubscriptionUrl, @@ -33,7 +33,7 @@ export function CancelAiAddOnModal() { if (!personalSubscription) return null - const onStandalone = personalSubscription.planCode === AI_STANDALONE_PLAN_CODE + const onStandalone = isStandaloneAiPlanCode(personalSubscription.planCode) const cancellationEndpoint = onStandalone ? cancelSubscriptionUrl 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..bcf51e7891 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 && ( - {t('total_per_month')} + + {preview.nextPlan.annual + ? t('total_per_year') + : t('total_per_month')} + {formatCurrencyLocalized( preview.nextInvoice.total, diff --git a/services/web/frontend/js/features/subscription/data/add-on-codes.ts b/services/web/frontend/js/features/subscription/data/add-on-codes.ts index cb32f1fa4c..c13efaa720 100644 --- a/services/web/frontend/js/features/subscription/data/add-on-codes.ts +++ b/services/web/frontend/js/features/subscription/data/add-on-codes.ts @@ -3,4 +3,8 @@ export const AI_ADD_ON_CODE = 'assistant' // we dont want translations on plan or add-on names export const ADD_ON_NAME = "Error Assist" export const AI_STANDALONE_PLAN_NAME = "Overleaf Free" -export const AI_STANDALONE_ANNUAL_PLAN_CODE = 'assistant-annual' \ No newline at end of file +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 +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index fcc55752ed..4f9c8f3996 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -79,6 +79,7 @@ "add_email": "Add Email", "add_email_address": "Add email address", "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_more_collaborators": "Add more collaborators", "add_more_editors": "Add more editors", @@ -2059,6 +2060,7 @@ "submit_title": "Submit", "subscribe": "Subscribe", "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_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.", diff --git a/services/web/package.json b/services/web/package.json index 412f883e76..4db823c994 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -234,7 +234,6 @@ "@types/chai": "^4.3.0", "@types/dateformat": "^5.0.2", "@types/diff": "^5.0.9", - "uuid": "^9.0.1", "@types/dompurify": "^3.0.5", "@types/events": "^3.0.0", "@types/express": "^4.17.13", @@ -258,8 +257,8 @@ "@uppy/react": "^3.2.1", "@uppy/utils": "^5.7.0", "@uppy/xhr-upload": "^3.6.0", - "abort-controller": "^3.0.0", "5to6-codemod": "^1.8.0", + "abort-controller": "^3.0.0", "acorn": "^7.1.1", "acorn-walk": "^7.1.1", "algoliasearch": "^3.35.1", @@ -323,6 +322,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", @@ -363,6 +363,7 @@ "to-string-loader": "^1.2.0", "tty-browserify": "^0.0.1", "typescript": "^5.0.4", + "uuid": "^9.0.1", "w3c-keyname": "^2.2.8", "webpack": "^5.93.0", "webpack-assets-manifest": "^5.2.1", diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 9e861fead3..780e8fb606 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -3,6 +3,7 @@ const { expect } = require('chai') const recurly = require('recurly') const SandboxedModule = require('sandboxed-module') const { + RecurlySubscription, RecurlySubscriptionChangeRequest, RecurlySubscriptionAddOnUpdate, } = require('../../../../app/src/Features/Subscription/RecurlyEntities') @@ -35,7 +36,7 @@ describe('RecurlyClient', function () { preTaxTotal: 2, } - this.subscription = { + this.subscription = new RecurlySubscription({ id: 'subscription-id', userId: 'user-id', currency: 'EUR', @@ -49,7 +50,7 @@ describe('RecurlyClient', function () { total: 16.5, periodStart: new Date(), periodEnd: new Date(), - } + }) this.recurlySubscription = { uuid: this.subscription.id, @@ -96,6 +97,7 @@ describe('RecurlyClient', function () { let client this.client = client = { getAccount: sinon.stub(), + listAccountSubscriptions: sinon.stub(), } this.recurly = { 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 () { beforeEach(function () { this.client.createSubscriptionChange = sinon diff --git a/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js b/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js index aed3951ed3..7331118739 100644 --- a/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js +++ b/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js @@ -4,9 +4,11 @@ const SandboxedModule = require('sandboxed-module') const { expect } = require('chai') const Errors = require('../../../../app/src/Features/Subscription/Errors') const { + AI_ADD_ON_CODE, RecurlySubscriptionChangeRequest, RecurlySubscriptionChange, RecurlySubscription, + RecurlySubscriptionAddOnUpdate, } = require('../../../../app/src/Features/Subscription/RecurlyEntities') const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities' @@ -16,6 +18,7 @@ describe('RecurlyEntities', function () { beforeEach(function () { this.Settings = { plans: [ + { planCode: 'assistant-annual', price_in_cents: 5900 }, { planCode: 'cheap-plan', price_in_cents: 500 }, { planCode: 'regular-plan', price_in_cents: 1000 }, { 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 () { diff --git a/services/web/types/subscription/subscription-change-preview.ts b/services/web/types/subscription/subscription-change-preview.ts index 4b0b83bbb5..dab0970151 100644 --- a/services/web/types/subscription/subscription-change-preview.ts +++ b/services/web/types/subscription/subscription-change-preview.ts @@ -1,8 +1,11 @@ export type SubscriptionChangePreview = { - change: SubscriptionChange + change: SubscriptionChangeDescription currency: string paymentMethod: string immediateCharge: number + nextPlan: { + annual: boolean + } nextInvoice: { date: string plan: { @@ -27,7 +30,7 @@ type AddOn = { amount: number } -type SubscriptionChange = AddOnPurchase +export type SubscriptionChangeDescription = AddOnPurchase | PremiumSubscription type AddOnPurchase = { type: 'add-on-purchase' @@ -36,3 +39,11 @@ type AddOnPurchase = { name: string } } + +type PremiumSubscription = { + type: 'premium-subscription' + plan: { + code: string + name: string + } +}