From f7f4a03abb7b8a933530ac112e8848b46c39dcaf Mon Sep 17 00:00:00 2001 From: Jimmy Domagala-Tang Date: Wed, 9 Apr 2025 12:29:19 -0400 Subject: [PATCH] Merge pull request #24523 from overleaf/jdt-prevent-bundle-dbl-buys Redirect bundle purchases when users already have it GitOrigin-RevId: d8e3c0256db08c08c2be24f38caef91fb26b90e8 --- .../Features/Subscription/FeaturesUpdater.js | 10 +++++++++ .../Features/Subscription/RecurlyEntities.js | 17 ++++++++++++++ .../Subscription/SubscriptionController.js | 18 ++++++++++----- .../web/frontend/extracted-translations.json | 2 ++ .../dashboard/personal-subscription.tsx | 22 +++++++++++++++++++ services/web/locales/en.json | 2 ++ 6 files changed, 65 insertions(+), 6 deletions(-) diff --git a/services/web/app/src/Features/Subscription/FeaturesUpdater.js b/services/web/app/src/Features/Subscription/FeaturesUpdater.js index 1e7a868fc0..3eae1942a6 100644 --- a/services/web/app/src/Features/Subscription/FeaturesUpdater.js +++ b/services/web/app/src/Features/Subscription/FeaturesUpdater.js @@ -197,6 +197,14 @@ async function doSyncFromV1(v1UserId) { return refreshFeatures(user._id, 'sync-v1') } +async function hasFeaturesViaWritefull(userId) { + const user = await UserGetter.promises.getUser(userId, { + _id: 1, + writefull: 1, + }) + return Boolean(user?.writefull?.isPremium) +} + module.exports = { featuresEpochIsCurrent, computeFeatures: callbackify(computeFeatures), @@ -209,10 +217,12 @@ module.exports = { 'featuresChanged', ]), scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures), + hasFeaturesViaWritefull: callbackify(hasFeaturesViaWritefull), promises: { computeFeatures, refreshFeatures, scheduleRefreshFeatures, doSyncFromV1, + hasFeaturesViaWritefull, }, } diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/RecurlyEntities.js index 9c5eaaeac6..2acb7a6c80 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEntities.js +++ b/services/web/app/src/Features/Subscription/RecurlyEntities.js @@ -477,6 +477,22 @@ function isStandaloneAiAddOnPlanCode(planCode) { return STANDALONE_AI_ADD_ON_CODES.includes(planCode) } +/** + * Returns whether subscription change will have have the ai bundle once the change is processed + * + * @param {RecurlySubscriptionChange} subscriptionChange The subscription change object coming from Recurly + * + * @return {boolean} + */ +function subscriptionChangeIsAiAssistUpgrade(subscriptionChange) { + return Boolean( + isStandaloneAiAddOnPlanCode(subscriptionChange.nextPlanCode) || + subscriptionChange.nextAddOns?.some( + addOn => addOn.code === AI_ADD_ON_CODE + ) + ) +} + module.exports = { AI_ADD_ON_CODE, MEMBERS_LIMIT_ADD_ON_CODE, @@ -493,5 +509,6 @@ module.exports = { RecurlyCoupon, RecurlyAccount, isStandaloneAiAddOnPlanCode, + subscriptionChangeIsAiAssistUpgrade, RecurlyImmediateCharge, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index cb8a293fb4..c71413ad9e 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -24,6 +24,7 @@ const HttpErrorHandler = require('../Errors/HttpErrorHandler') const RecurlyClient = require('./RecurlyClient') const { AI_ADD_ON_CODE } = require('./RecurlyEntities') const PlansLocator = require('./PlansLocator') +const RecurlyEntities = require('./RecurlyEntities') /** * @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview' @@ -45,7 +46,6 @@ function formatGroupPlansDataForDash() { async function userSubscriptionPage(req, res) { const user = SessionManager.getSessionUser(req.session) - await SplitTestHandler.promises.getAssignment(req, res, 'pause-subscription') const groupPricingDiscount = await SplitTestHandler.promises.getAssignment( @@ -321,13 +321,19 @@ async function previewAddonPurchase(req, res) { try { subscriptionChange = await SubscriptionHandler.promises.previewAddonPurchase(userId, addOnCode) + + const hasBundleViaWritefull = + await FeaturesUpdater.promises.hasFeaturesViaWritefull(userId) + const isAiUpgrade = + RecurlyEntities.subscriptionChangeIsAiAssistUpgrade(subscriptionChange) + if (hasBundleViaWritefull && isAiUpgrade) { + return res.redirect( + '/user/subscription?redirect-reason=writefull-entitled' + ) + } } catch (err) { if (err instanceof DuplicateAddOnError) { - return HttpErrorHandler.badRequest( - req, - res, - `Subscription already has add-on "${addOnCode}"` - ) + return res.redirect('/user/subscription?redirect-reason=double-buy') } throw err } diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 56b5622d93..f674e6c5a2 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -670,6 +670,8 @@ "go_to_pdf_location_in_code": "", "go_to_settings": "", "go_to_subscriptions": "", + "good_news_you_already_purchased_this_add_on": "", + "good_news_you_are_already_receiving_this_add_on_via_writefull": "", "group_admin": "", "group_invitations": "", "group_invite_has_been_sent_to_email": "", 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 50c438f9d9..b567a39667 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 @@ -33,6 +33,27 @@ function PastDueSubscriptionAlert({ ) } +function RedirectAlerts() { + const queryParams = new URLSearchParams(window.location.search) + const redirectReason = queryParams.get('redirect-reason') + const { t } = useTranslation() + + if (!redirectReason) { + return null + } + + let warning + if (redirectReason === 'writefull-entitled') { + warning = t('good_news_you_are_already_receiving_this_add_on_via_writefull') + } else if (redirectReason === 'double-buy') { + warning = t('good_news_you_already_purchased_this_add_on') + } else { + return null + } + + return {warning}} /> +} + function PersonalSubscriptionStates({ subscription, }: { @@ -75,6 +96,7 @@ function PersonalSubscription() { return ( <> + {personalSubscription.recurly.hasPastDueInvoice && ( )} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 1269422922..fd893c66b1 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -884,6 +884,8 @@ "go_to_previous_page": "Go to previous page", "go_to_settings": "Go to settings", "go_to_subscriptions": "Go to Subscriptions", + "good_news_you_already_purchased_this_add_on": "Good news! You already have this add-on, so no need to pay again.", + "good_news_you_are_already_receiving_this_add_on_via_writefull": "Good news! You already have this add-on via your Writefull subscription. No need to pay again.", "great_for_getting_started": "Great for getting started", "great_for_small_teams_and_departments": "Great for small teams and departments", "group": "Group",