[web] support purchasing/removing add-ons for Stripe subscriptions (#26081)

GitOrigin-RevId: 01c2eaccc7c34bc37be43120de83270490e5e6da
This commit is contained in:
Kristina
2025-06-05 08:05:06 +00:00
committed by Copybot
parent 266f2c47c0
commit 400c037057
5 changed files with 82 additions and 60 deletions
@@ -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,
}
@@ -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,
@@ -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<PaymentProviderSubscription>}
*/
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) {
@@ -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 } =
+6
View File
@@ -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'