mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-09 09:09:02 +02:00
Merge pull request #26574 from overleaf/ls-group-plan-seats-adding-in-stripe
Support group plan seats adding in Stripe GitOrigin-RevId: 9c46c167388c5578a1513f908e409ab5d940c1df
This commit is contained in:
@@ -20,7 +20,6 @@ import {
|
||||
SubtotalLimitExceededError,
|
||||
HasPastDueInvoiceError,
|
||||
} from './Errors.js'
|
||||
import RecurlyClient from './RecurlyClient.js'
|
||||
|
||||
/**
|
||||
* @import { Subscription } from "../../../../types/subscription/dashboard/subscription.js"
|
||||
@@ -138,13 +137,13 @@ async function _removeUserFromGroup(
|
||||
async function addSeatsToGroupSubscription(req, res) {
|
||||
try {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { subscription, recurlySubscription, plan } =
|
||||
const { subscription, paymentProviderSubscription, plan } =
|
||||
await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
|
||||
userId
|
||||
)
|
||||
await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
|
||||
await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges(
|
||||
recurlySubscription
|
||||
paymentProviderSubscription
|
||||
)
|
||||
await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive(
|
||||
subscription
|
||||
@@ -162,15 +161,15 @@ async function addSeatsToGroupSubscription(req, res) {
|
||||
|
||||
if (flexibleLicensingForManuallyBilledSubscriptionsVariant === 'enabled') {
|
||||
await SubscriptionGroupHandler.promises.checkBillingInfoExistence(
|
||||
recurlySubscription,
|
||||
paymentProviderSubscription,
|
||||
userId
|
||||
)
|
||||
} else {
|
||||
await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual(
|
||||
recurlySubscription
|
||||
paymentProviderSubscription
|
||||
)
|
||||
// Check if the user has missing billing details
|
||||
await RecurlyClient.promises.getPaymentMethod(userId)
|
||||
await Modules.promises.hooks.fire('getPaymentMethod', userId)
|
||||
}
|
||||
|
||||
res.render('subscriptions/add-seats', {
|
||||
@@ -178,7 +177,8 @@ async function addSeatsToGroupSubscription(req, res) {
|
||||
groupName: subscription.teamName,
|
||||
totalLicenses: subscription.membersLimit,
|
||||
isProfessional: isProfessionalGroupPlan(subscription),
|
||||
isCollectionMethodManual: recurlySubscription.isCollectionMethodManual,
|
||||
isCollectionMethodManual:
|
||||
paymentProviderSubscription.isCollectionMethodManual,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof MissingBillingInfoError) {
|
||||
@@ -300,15 +300,15 @@ async function submitForm(req, res) {
|
||||
const userEmail = await UserGetter.promises.getUserEmail(userId)
|
||||
const { adding, poNumber } = req.body
|
||||
|
||||
const { recurlySubscription } =
|
||||
const { paymentProviderSubscription } =
|
||||
await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails(
|
||||
userId
|
||||
)
|
||||
|
||||
if (recurlySubscription.isCollectionMethodManual) {
|
||||
if (paymentProviderSubscription.isCollectionMethodManual) {
|
||||
await SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms(
|
||||
userId,
|
||||
recurlySubscription,
|
||||
paymentProviderSubscription,
|
||||
poNumber
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ const { Subscription } = require('../../models/Subscription')
|
||||
const { User } = require('../../models/User')
|
||||
const RecurlyClient = require('./RecurlyClient')
|
||||
const PlansLocator = require('./PlansLocator')
|
||||
const SubscriptionHandler = require('./SubscriptionHandler')
|
||||
const TeamInvitesHandler = require('./TeamInvitesHandler')
|
||||
const GroupPlansData = require('./GroupPlansData')
|
||||
const Modules = require('../../infrastructure/Modules')
|
||||
@@ -86,22 +85,24 @@ async function ensureSubscriptionIsActive(subscription) {
|
||||
}
|
||||
|
||||
async function ensureSubscriptionCollectionMethodIsNotManual(
|
||||
recurlySubscription
|
||||
paymentProviderSubscription
|
||||
) {
|
||||
if (recurlySubscription.isCollectionMethodManual) {
|
||||
if (paymentProviderSubscription.isCollectionMethodManual) {
|
||||
throw new ManuallyCollectedError(
|
||||
'This subscription is being collected manually',
|
||||
{
|
||||
recurlySubscription_id: recurlySubscription.id,
|
||||
subscription_id: paymentProviderSubscription.id,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSubscriptionHasNoPendingChanges(recurlySubscription) {
|
||||
if (recurlySubscription.pendingChange) {
|
||||
async function ensureSubscriptionHasNoPendingChanges(
|
||||
paymentProviderSubscription
|
||||
) {
|
||||
if (paymentProviderSubscription.pendingChange) {
|
||||
throw new PendingChangeError('This subscription has a pending change', {
|
||||
recurlySubscription_id: recurlySubscription.id,
|
||||
subscription_id: paymentProviderSubscription.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -136,47 +137,50 @@ async function getUsersGroupSubscriptionDetails(userId) {
|
||||
|
||||
const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
|
||||
|
||||
const recurlySubscription = await RecurlyClient.promises.getSubscription(
|
||||
subscription.recurlySubscription_id
|
||||
const response = await Modules.promises.hooks.fire(
|
||||
'getPaymentFromRecord',
|
||||
subscription
|
||||
)
|
||||
|
||||
const { subscription: paymentProviderSubscription } = response[0]
|
||||
|
||||
return {
|
||||
userId,
|
||||
subscription,
|
||||
recurlySubscription,
|
||||
paymentProviderSubscription,
|
||||
plan,
|
||||
}
|
||||
}
|
||||
|
||||
async function checkBillingInfoExistence(recurlySubscription, userId) {
|
||||
async function checkBillingInfoExistence(paymentProviderSubscription, userId) {
|
||||
// Verify the billing info only if the collection method is not manual (e.g. automatic)
|
||||
if (!recurlySubscription.isCollectionMethodManual) {
|
||||
if (!paymentProviderSubscription.isCollectionMethodManual) {
|
||||
// Check if the user has missing billing details
|
||||
await RecurlyClient.promises.getPaymentMethod(userId)
|
||||
await Modules.promises.hooks.fire('getPaymentMethod', userId)
|
||||
}
|
||||
}
|
||||
|
||||
async function _addSeatsSubscriptionChange(userId, adding) {
|
||||
const { subscription, recurlySubscription, plan } =
|
||||
const { subscription, paymentProviderSubscription, plan } =
|
||||
await getUsersGroupSubscriptionDetails(userId)
|
||||
await ensureFlexibleLicensingEnabled(plan)
|
||||
await ensureSubscriptionIsActive(subscription)
|
||||
await ensureSubscriptionHasNoPendingChanges(recurlySubscription)
|
||||
await checkBillingInfoExistence(recurlySubscription, userId)
|
||||
await ensureSubscriptionHasNoPendingChanges(paymentProviderSubscription)
|
||||
await checkBillingInfoExistence(paymentProviderSubscription, userId)
|
||||
await ensureSubscriptionHasNoPastDueInvoice(subscription)
|
||||
|
||||
const currentAddonQuantity =
|
||||
recurlySubscription.addOns.find(
|
||||
paymentProviderSubscription.addOns.find(
|
||||
addOn => addOn.code === MEMBERS_LIMIT_ADD_ON_CODE
|
||||
)?.quantity ?? 0
|
||||
// Keeps only the new total quantity of addon
|
||||
const nextAddonQuantity = currentAddonQuantity + adding
|
||||
|
||||
let changeRequest
|
||||
if (recurlySubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE)) {
|
||||
if (paymentProviderSubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE)) {
|
||||
// Not providing a custom price as once the subscription is locked
|
||||
// to an add-on at a given price, it will use it for subsequent payments
|
||||
changeRequest = recurlySubscription.getRequestForAddOnUpdate(
|
||||
changeRequest = paymentProviderSubscription.getRequestForAddOnUpdate(
|
||||
MEMBERS_LIMIT_ADD_ON_CODE,
|
||||
nextAddonQuantity
|
||||
)
|
||||
@@ -185,7 +189,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
|
||||
const pattern =
|
||||
/^group_(collaborator|professional)_(2|3|4|5|10|20|50)_(educational|enterprise)$/
|
||||
const [, planCode, size, usage] = plan.planCode.match(pattern)
|
||||
const currency = recurlySubscription.currency
|
||||
const currency = paymentProviderSubscription.currency
|
||||
const planPriceInCents =
|
||||
GroupPlansData[usage][planCode][currency][size].price_in_cents
|
||||
const legacyUnitPriceInCents =
|
||||
@@ -194,7 +198,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
|
||||
|
||||
if (
|
||||
_shouldUseLegacyPricing(
|
||||
recurlySubscription.planPrice,
|
||||
paymentProviderSubscription.planPrice,
|
||||
planPriceInCents / 100,
|
||||
usage,
|
||||
size
|
||||
@@ -203,7 +207,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
|
||||
unitPrice = legacyUnitPriceInCents / 100
|
||||
}
|
||||
|
||||
changeRequest = recurlySubscription.getRequestForAddOnPurchase(
|
||||
changeRequest = paymentProviderSubscription.getRequestForAddOnPurchase(
|
||||
MEMBERS_LIMIT_ADD_ON_CODE,
|
||||
nextAddonQuantity,
|
||||
unitPrice
|
||||
@@ -213,7 +217,7 @@ async function _addSeatsSubscriptionChange(userId, adding) {
|
||||
return {
|
||||
changeRequest,
|
||||
currentAddonQuantity,
|
||||
recurlySubscription,
|
||||
paymentProviderSubscription,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,37 +241,38 @@ function _shouldUseLegacyPricing(
|
||||
async function previewAddSeatsSubscriptionChange(userId, adding) {
|
||||
const { changeRequest, currentAddonQuantity } =
|
||||
await _addSeatsSubscriptionChange(userId, adding)
|
||||
const subscriptionChange =
|
||||
await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
|
||||
const subscriptionChangePreview =
|
||||
await SubscriptionController.makeChangePreview(
|
||||
{
|
||||
type: 'add-on-update',
|
||||
addOn: {
|
||||
code: MEMBERS_LIMIT_ADD_ON_CODE,
|
||||
quantity: subscriptionChange.nextAddOns.find(
|
||||
addon => addon.code === MEMBERS_LIMIT_ADD_ON_CODE
|
||||
).quantity,
|
||||
prevQuantity: currentAddonQuantity,
|
||||
},
|
||||
const response = await Modules.promises.hooks.fire(
|
||||
'previewSubscriptionChangeRequest',
|
||||
changeRequest
|
||||
)
|
||||
const subscriptionChange = response[0]
|
||||
const subscriptionChangePreview = SubscriptionController.makeChangePreview(
|
||||
{
|
||||
type: 'add-on-update',
|
||||
addOn: {
|
||||
code: MEMBERS_LIMIT_ADD_ON_CODE,
|
||||
quantity: subscriptionChange.nextAddOns.find(
|
||||
addon => addon.code === MEMBERS_LIMIT_ADD_ON_CODE
|
||||
).quantity,
|
||||
prevQuantity: currentAddonQuantity,
|
||||
},
|
||||
subscriptionChange
|
||||
)
|
||||
},
|
||||
subscriptionChange
|
||||
)
|
||||
|
||||
return subscriptionChangePreview
|
||||
}
|
||||
|
||||
async function createAddSeatsSubscriptionChange(userId, adding, poNumber) {
|
||||
const { changeRequest, recurlySubscription } =
|
||||
const { changeRequest, paymentProviderSubscription } =
|
||||
await _addSeatsSubscriptionChange(userId, adding)
|
||||
|
||||
if (recurlySubscription.isCollectionMethodManual) {
|
||||
await updateSubscriptionPaymentTerms(userId, recurlySubscription, poNumber)
|
||||
if (paymentProviderSubscription.isCollectionMethodManual) {
|
||||
await updateSubscriptionPaymentTerms(paymentProviderSubscription, poNumber)
|
||||
}
|
||||
|
||||
await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
|
||||
await SubscriptionHandler.promises.syncSubscription(
|
||||
{ uuid: recurlySubscription.id },
|
||||
await Modules.promises.hooks.fire(
|
||||
'applySubscriptionChangeRequestAndSync',
|
||||
changeRequest,
|
||||
userId
|
||||
)
|
||||
|
||||
@@ -275,21 +280,30 @@ async function createAddSeatsSubscriptionChange(userId, adding, poNumber) {
|
||||
}
|
||||
|
||||
async function updateSubscriptionPaymentTerms(
|
||||
userId,
|
||||
recurlySubscription,
|
||||
paymentProviderSubscription,
|
||||
poNumber
|
||||
) {
|
||||
if (paymentProviderSubscription.service?.includes('stripe')) {
|
||||
// TODO: Implement Stripe payment terms update
|
||||
throw new OError(
|
||||
'Updating payment terms is not supported for Stripe subscriptions',
|
||||
{
|
||||
subscriptionId: paymentProviderSubscription.id,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const [termsAndConditions] = await Modules.promises.hooks.fire(
|
||||
'generateTermsAndConditions',
|
||||
{ currency: recurlySubscription.currency, poNumber }
|
||||
{ currency: paymentProviderSubscription.currency, poNumber }
|
||||
)
|
||||
|
||||
const updateRequest = poNumber
|
||||
? recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
|
||||
? paymentProviderSubscription.getRequestForPoNumberAndTermsAndConditionsUpdate(
|
||||
poNumber,
|
||||
termsAndConditions
|
||||
)
|
||||
: recurlySubscription.getRequestForTermsAndConditionsUpdate(
|
||||
: paymentProviderSubscription.getRequestForTermsAndConditionsUpdate(
|
||||
termsAndConditions
|
||||
)
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) {
|
||||
*/
|
||||
async function previewSubscriptionChange(userId, planCode) {
|
||||
const change = await Modules.promises.hooks.fire(
|
||||
'previewSubscriptionChange',
|
||||
'previewPlanChange',
|
||||
userId,
|
||||
planCode
|
||||
)
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('SubscriptionGroupController', function () {
|
||||
getUsersGroupSubscriptionDetails: sinon.stub().resolves({
|
||||
subscription: ctx.subscription,
|
||||
plan: ctx.plan,
|
||||
recurlySubscription: ctx.recurlySubscription,
|
||||
paymentProviderSubscription: ctx.recurlySubscription,
|
||||
}),
|
||||
previewAddSeatsSubscriptionChange: sinon
|
||||
.stub()
|
||||
|
||||
@@ -193,21 +193,26 @@ describe('SubscriptionGroupHandler', function () {
|
||||
this.Modules = {
|
||||
promises: {
|
||||
hooks: {
|
||||
fire: sinon.stub().callsFake(hookName => {
|
||||
if (hookName === 'generateTermsAndConditions') {
|
||||
return Promise.resolve(['T&Cs'])
|
||||
}
|
||||
if (hookName === 'getPaymentFromRecord') {
|
||||
return Promise.resolve([
|
||||
{ account: { hasPastDueInvoice: false } },
|
||||
])
|
||||
}
|
||||
return Promise.resolve()
|
||||
}),
|
||||
fire: sinon.stub(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
this.Modules.promises.hooks.fire
|
||||
.withArgs('generateTermsAndConditions')
|
||||
.resolves(['T&Cs'])
|
||||
.withArgs('getPaymentFromRecord')
|
||||
.resolves([
|
||||
{
|
||||
subscription: this.recurlySubscription,
|
||||
account: { hasPastDueInvoice: false },
|
||||
},
|
||||
])
|
||||
.withArgs('previewSubscriptionChangeRequest')
|
||||
.resolves([this.previewSubscriptionChange])
|
||||
.withArgs('previewGroupPlanUpgrade')
|
||||
.resolves([{ subscriptionChange: this.previewSubscriptionChange }])
|
||||
|
||||
this.Handler = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'./SubscriptionUpdater': this.SubscriptionUpdater,
|
||||
@@ -389,7 +394,7 @@ describe('SubscriptionGroupHandler', function () {
|
||||
this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE,
|
||||
canUseFlexibleLicensing: true,
|
||||
},
|
||||
recurlySubscription: this.recurlySubscription,
|
||||
paymentProviderSubscription: this.recurlySubscription,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -441,11 +446,16 @@ describe('SubscriptionGroupHandler', function () {
|
||||
this.adminUser_id,
|
||||
this.adding
|
||||
)
|
||||
this.RecurlyClient.promises.getPaymentMethod
|
||||
.calledWith(this.adminUser_id)
|
||||
this.Modules.promises.hooks.fire
|
||||
.calledWith('getPaymentFromRecord', {
|
||||
groupPlan: true,
|
||||
recurlyStatus: {
|
||||
state: 'active',
|
||||
},
|
||||
})
|
||||
.should.equal(true)
|
||||
this.RecurlyClient.promises.previewSubscriptionChange
|
||||
.calledWith(this.changeRequest)
|
||||
this.Modules.promises.hooks.fire
|
||||
.calledWith('previewSubscriptionChangeRequest', this.changeRequest)
|
||||
.should.equal(true)
|
||||
this.SubscriptionController.makeChangePreview
|
||||
.calledWith(
|
||||
@@ -473,9 +483,14 @@ describe('SubscriptionGroupHandler', function () {
|
||||
return true
|
||||
},
|
||||
}
|
||||
this.RecurlyClient.promises.getSubscription = sinon
|
||||
.stub()
|
||||
.resolves(this.recurlySubscription)
|
||||
this.Modules.promises.hooks.fire
|
||||
.withArgs('getPaymentFromRecord')
|
||||
.resolves([
|
||||
{
|
||||
subscription: this.recurlySubscription,
|
||||
account: { hasPastDueInvoice: false },
|
||||
},
|
||||
])
|
||||
|
||||
const result =
|
||||
await this.Handler.promises.createAddSeatsSubscriptionChange(
|
||||
@@ -491,13 +506,10 @@ describe('SubscriptionGroupHandler', function () {
|
||||
.and(sinon.match.has('termsAndConditions'))
|
||||
)
|
||||
.should.equal(true)
|
||||
this.RecurlyClient.promises.applySubscriptionChangeRequest
|
||||
.calledWith(this.changeRequest)
|
||||
.should.equal(true)
|
||||
this.SubscriptionHandler.promises.syncSubscription
|
||||
this.Modules.promises.hooks.fire
|
||||
.calledWith(
|
||||
{ uuid: this.recurlySubscription.id },
|
||||
this.adminUser_id
|
||||
'applySubscriptionChangeRequestAndSync',
|
||||
this.changeRequest
|
||||
)
|
||||
.should.equal(true)
|
||||
expect(result).to.deep.equal({
|
||||
@@ -511,7 +523,6 @@ describe('SubscriptionGroupHandler', function () {
|
||||
describe('accounts with PO number', function () {
|
||||
it('should update the subscription PO number and T&C', async function () {
|
||||
await this.Handler.promises.updateSubscriptionPaymentTerms(
|
||||
this.adminUser_id,
|
||||
this.recurlySubscription,
|
||||
this.poNumberAndTermsAndConditionsUpdate.poNumber
|
||||
)
|
||||
@@ -525,12 +536,23 @@ describe('SubscriptionGroupHandler', function () {
|
||||
.calledWith(this.poNumberAndTermsAndConditionsUpdate)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
it('should fail for stripe', async function () {
|
||||
this.recurlySubscription.service = 'stripe'
|
||||
await expect(
|
||||
this.Handler.promises.updateSubscriptionPaymentTerms(
|
||||
this.recurlySubscription,
|
||||
this.poNumberAndTermsAndConditionsUpdate.poNumber
|
||||
)
|
||||
).to.be.rejectedWith(
|
||||
'Updating payment terms is not supported for Stripe subscriptions'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('accounts with no PO number', function () {
|
||||
it('should update the subscription T&C only', async function () {
|
||||
await this.Handler.promises.updateSubscriptionPaymentTerms(
|
||||
this.adminUser_id,
|
||||
this.recurlySubscription
|
||||
)
|
||||
this.recurlySubscription.getRequestForTermsAndConditionsUpdate
|
||||
@@ -570,11 +592,16 @@ describe('SubscriptionGroupHandler', function () {
|
||||
let preview
|
||||
|
||||
afterEach(function () {
|
||||
this.RecurlyClient.promises.getPaymentMethod
|
||||
.calledWith(this.adminUser_id)
|
||||
this.Modules.promises.hooks.fire
|
||||
.calledWith('getPaymentFromRecord', {
|
||||
groupPlan: true,
|
||||
recurlyStatus: {
|
||||
state: 'active',
|
||||
},
|
||||
})
|
||||
.should.equal(true)
|
||||
this.RecurlyClient.promises.previewSubscriptionChange
|
||||
.calledWith(this.changeRequest)
|
||||
this.Modules.promises.hooks.fire
|
||||
.calledWith('previewSubscriptionChangeRequest', this.changeRequest)
|
||||
.should.equal(true)
|
||||
this.SubscriptionController.makeChangePreview
|
||||
.calledWith(
|
||||
@@ -781,9 +808,6 @@ describe('SubscriptionGroupHandler', function () {
|
||||
|
||||
describe('getGroupPlanUpgradePreview', function () {
|
||||
it('should generate preview for subscription upgrade', async function () {
|
||||
this.Modules.promises.hooks.fire.resolves([
|
||||
{ subscriptionChange: this.previewSubscriptionChange },
|
||||
])
|
||||
const result = await this.Handler.promises.getGroupPlanUpgradePreview(
|
||||
this.user_id
|
||||
)
|
||||
@@ -797,8 +821,8 @@ describe('SubscriptionGroupHandler', function () {
|
||||
this.recurlySubscription,
|
||||
this.adminUser_id
|
||||
)
|
||||
this.RecurlyClient.promises.getPaymentMethod
|
||||
.calledWith(this.adminUser_id)
|
||||
this.Modules.promises.hooks.fire
|
||||
.calledWith('getPaymentMethod', this.adminUser_id)
|
||||
.should.equal(true)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user