mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 06:03:45 +01:00
[web] support purchasing/removing add-ons for Stripe subscriptions (#26081)
GitOrigin-RevId: 01c2eaccc7c34bc37be43120de83270490e5e6da
This commit is contained in:
@@ -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 } =
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user