From a134a2b799740d553e5c325ef9550884b0ed831d Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Wed, 4 Jun 2025 11:02:14 +0200 Subject: [PATCH] [web] support purchasing/removing add-ons for Stripe subscriptions (#26081) GitOrigin-RevId: 01c2eaccc7c34bc37be43120de83270490e5e6da --- .../src/Features/Subscription/PlansLocator.js | 31 +++++++++- .../Subscription/SubscriptionController.js | 2 - .../Subscription/SubscriptionHandler.js | 62 ++----------------- .../src/Subscription/PlansLocatorTests.js | 41 ++++++++++++ services/web/types/subscription/plan.ts | 6 ++ 5 files changed, 82 insertions(+), 60 deletions(-) diff --git a/services/web/app/src/Features/Subscription/PlansLocator.js b/services/web/app/src/Features/Subscription/PlansLocator.js index 24343e1109..c04f0c860d 100644 --- a/services/web/app/src/Features/Subscription/PlansLocator.js +++ b/services/web/app/src/Features/Subscription/PlansLocator.js @@ -4,7 +4,9 @@ const logger = require('@overleaf/logger') /** * @typedef {import('../../../../types/subscription/plan').RecurlyPlanCode} RecurlyPlanCode + * @typedef {import('../../../../types/subscription/plan').RecurlyAddOnCode} RecurlyAddOnCode * @typedef {import('../../../../types/subscription/plan').StripeLookupKey} StripeLookupKey + * @typedef {import('stripe').Stripe.Price.Recurring.Interval} BillingCycleInterval */ function ensurePlansAreSetupCorrectly() { @@ -38,7 +40,7 @@ const recurlyPlanCodeToStripeLookupKey = { group_professional_educational: 'group_professional_educational', group_collaborator: 'group_standard_enterprise', group_collaborator_educational: 'group_standard_educational', - assistant_annual: 'error_assist_annual', + 'assistant-annual': 'error_assist_annual', assistant: 'error_assist_monthly', } @@ -51,6 +53,28 @@ function mapRecurlyPlanCodeToStripeLookupKey(recurlyPlanCode) { return recurlyPlanCodeToStripeLookupKey[recurlyPlanCode] } +/** + * + * @param {RecurlyAddOnCode} recurlyAddOnCode + * @param {BillingCycleInterval} billingCycleInterval + * @returns {StripeLookupKey|null} + */ +function mapRecurlyAddOnCodeToStripeLookupKey( + recurlyAddOnCode, + billingCycleInterval +) { + // Recurly always uses 'assistant' as the code regardless of the subscription duration + if (recurlyAddOnCode === 'assistant') { + if (billingCycleInterval === 'month') { + return 'error_assist_monthly' + } + if (billingCycleInterval === 'year') { + return 'error_assist_annual' + } + } + return null +} + const recurlyPlanCodeToPlanTypeAndPeriod = { collaborator: { planType: 'individual', period: 'monthly' }, collaborator_free_trial_7_days: { planType: 'individual', period: 'monthly' }, @@ -68,12 +92,14 @@ const recurlyPlanCodeToPlanTypeAndPeriod = { group_professional_educational: { planType: 'group', period: 'annual' }, group_collaborator: { planType: 'group', period: 'annual' }, group_collaborator_educational: { planType: 'group', period: 'annual' }, + assistant: { planType: null, period: 'monthly' }, + 'assistant-annual': { planType: null, period: 'annual' }, } /** * * @param {RecurlyPlanCode} recurlyPlanCode - * @returns {{ planType: 'individual' | 'group' | 'student', period: 'annual' | 'monthly'}} + * @returns {{ planType: 'individual' | 'group' | 'student' | null, period: 'annual' | 'monthly'}} */ function getPlanTypeAndPeriodFromRecurlyPlanCode(recurlyPlanCode) { return recurlyPlanCodeToPlanTypeAndPeriod[recurlyPlanCode] @@ -92,5 +118,6 @@ module.exports = { ensurePlansAreSetupCorrectly, findLocalPlanInSettings, mapRecurlyPlanCodeToStripeLookupKey, + mapRecurlyAddOnCodeToStripeLookupKey, getPlanTypeAndPeriodFromRecurlyPlanCode, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 4be61d255c..aa0b97d497 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -414,8 +414,6 @@ async function purchaseAddon(req, res, next) { logger.debug({ userId: user._id, addOnCode }, 'purchasing add-ons') try { - // set a restore point in the case of a failed payment for the upgrade (Recurly only) - await SubscriptionHandler.promises.setSubscriptionRestorePoint(user._id) await SubscriptionHandler.promises.purchaseAddon( user._id, addOnCode, diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 1296a2a7de..8aa0ee84eb 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -1,6 +1,5 @@ // @ts-check -const recurly = require('recurly') const RecurlyWrapper = require('./RecurlyWrapper') const RecurlyClient = require('./RecurlyClient') const { User } = require('../../models/User') @@ -11,11 +10,11 @@ const LimitationsManager = require('./LimitationsManager') const EmailHandler = require('../Email/EmailHandler') const { callbackify } = require('@overleaf/promise-utils') const UserUpdater = require('../User/UserUpdater') -const { NotFoundError, IndeterminateInvoiceError } = require('../Errors/Errors') +const { IndeterminateInvoiceError } = require('../Errors/Errors') const Modules = require('../../infrastructure/Modules') /** - * @import { PaymentProviderSubscription, PaymentProviderSubscriptionChange } from './PaymentProviderEntities' + * @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities' */ async function validateNoSubscriptionInRecurly(userId) { @@ -278,24 +277,12 @@ async function previewAddonPurchase(userId, addOnCode) { * @param {number} quantity */ async function purchaseAddon(userId, addOnCode, quantity) { - const subscription = await getSubscriptionForUser(userId) - try { - await RecurlyClient.promises.getAddOn(subscription.planCode, addOnCode) - } catch (err) { - if (err instanceof recurly.errors.NotFoundError) { - throw new NotFoundError({ - message: 'Add-on not found', - info: { addOnCode }, - }) - } - throw err - } - const changeRequest = subscription.getRequestForAddOnPurchase( + await Modules.promises.hooks.fire( + 'purchaseAddOn', + userId, addOnCode, quantity ) - await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) - await syncSubscription({ uuid: subscription.id }, userId) } /** @@ -305,44 +292,7 @@ async function purchaseAddon(userId, addOnCode, quantity) { * @param {string} addOnCode */ async function removeAddon(userId, addOnCode) { - const subscription = await getSubscriptionForUser(userId) - const changeRequest = subscription.getRequestForAddOnRemoval(addOnCode) - await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) - await syncSubscription({ uuid: subscription.id }, userId) -} - -/** - * Returns the Recurly UUID for the given user - * - * Throws a NotFoundError if the subscription can't be found - * - * @param {string} userId - * @return {Promise} - */ -async function getSubscriptionForUser(userId) { - const subscription = - await SubscriptionLocator.promises.getUsersSubscription(userId) - const recurlyId = subscription?.recurlySubscription_id - if (recurlyId == null) { - throw new NotFoundError({ - message: 'Recurly subscription not found', - info: { userId }, - }) - } - - try { - const subscription = await RecurlyClient.promises.getSubscription(recurlyId) - return subscription - } catch (err) { - if (err instanceof recurly.errors.NotFoundError) { - throw new NotFoundError({ - message: 'Subscription not found', - info: { userId, recurlyId }, - }) - } else { - throw err - } - } + await Modules.promises.hooks.fire('removeAddOn', userId, addOnCode) } async function pauseSubscription(user, pauseCycles) { diff --git a/services/web/test/unit/src/Subscription/PlansLocatorTests.js b/services/web/test/unit/src/Subscription/PlansLocatorTests.js index f705baa01c..0c7a6dca03 100644 --- a/services/web/test/unit/src/Subscription/PlansLocatorTests.js +++ b/services/web/test/unit/src/Subscription/PlansLocatorTests.js @@ -29,6 +29,7 @@ const plans = [ describe('PlansLocator', function () { beforeEach(function () { this.settings = { plans } + this.AI_ADD_ON_CODE = 'assistant' this.PlansLocator = SandboxedModule.require(modulePath, { requires: { @@ -114,6 +115,46 @@ describe('PlansLocator', function () { }) }) + describe('mapRecurlyAddOnCodeToStripeLookupKey', function () { + it('should return null for unknown add-on codes', function () { + const billingCycleInterval = 'month' + const addOnCode = 'unknown_addon' + const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey( + addOnCode, + billingCycleInterval + ) + expect(lookupKey).to.equal(null) + }) + + it('should handle missing input', function () { + const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey( + undefined, + undefined + ) + expect(lookupKey).to.equal(null) + }) + + it('returns the key for a monthly AI assist add-on', function () { + const billingCycleInterval = 'month' + const addOnCode = this.AI_ADD_ON_CODE + const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey( + addOnCode, + billingCycleInterval + ) + expect(lookupKey).to.equal('error_assist_monthly') + }) + + it('returns the key for an annual AI assist add-on', function () { + const billingCycleInterval = 'year' + const addOnCode = this.AI_ADD_ON_CODE + const lookupKey = this.PlansLocator.mapRecurlyAddOnCodeToStripeLookupKey( + addOnCode, + billingCycleInterval + ) + expect(lookupKey).to.equal('error_assist_annual') + }) + }) + describe('getPlanTypeAndPeriodFromRecurlyPlanCode', function () { it('should return the plan type and period for "collaborator"', function () { const { planType, period } = diff --git a/services/web/types/subscription/plan.ts b/services/web/types/subscription/plan.ts index c5e8f7e820..4759bb1255 100644 --- a/services/web/types/subscription/plan.ts +++ b/services/web/types/subscription/plan.ts @@ -85,6 +85,10 @@ export type RecurlyPlanCode = | 'group_professional_educational' | 'group_collaborator' | 'group_collaborator_educational' + | 'assistant' + | 'assistant-annual' + +export type RecurlyAddOnCode = 'assistant' export type StripeLookupKey = | 'standard_monthly' @@ -97,3 +101,5 @@ export type StripeLookupKey = | 'group_professional_enterprise' | 'group_standard_educational' | 'group_professional_educational' + | 'error_assist_annual' + | 'error_assist_monthly'