Merge pull request #24138 from overleaf/ii-flexible-licensing-colombian-pesos

[web] Recurly subtotal limit on flexible licensing

GitOrigin-RevId: 302fb15dcc360e3b47674e8e776ffa115af6cbe6
This commit is contained in:
ilkin-overleaf
2025-03-11 12:45:32 +02:00
committed by Copybot
parent 00aa26cd1a
commit 96af83a4ed
13 changed files with 336 additions and 19 deletions
@@ -22,6 +22,8 @@ class PendingChangeError extends OError {}
class InactiveError extends OError {}
class SubtotalLimitExceededError extends OError {}
module.exports = {
RecurlyTransactionError,
DuplicateAddOnError,
@@ -30,4 +32,5 @@ module.exports = {
ManuallyCollectedError,
PendingChangeError,
InactiveError,
SubtotalLimitExceededError,
}
@@ -16,7 +16,10 @@ const {
RecurlyPlan,
RecurlyImmediateCharge,
} = require('./RecurlyEntities')
const { MissingBillingInfoError } = require('./Errors')
const {
MissingBillingInfoError,
SubtotalLimitExceededError,
} = require('./Errors')
/**
* @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
@@ -116,14 +119,37 @@ async function getSubscriptionForUser(userId) {
*/
async function applySubscriptionChangeRequest(changeRequest) {
const body = subscriptionChangeRequestToApi(changeRequest)
const change = await client.createSubscriptionChange(
`uuid-${changeRequest.subscription.id}`,
body
)
logger.debug(
{ subscriptionId: changeRequest.subscription.id, changeId: change.id },
'created subscription change'
)
try {
const change = await client.createSubscriptionChange(
`uuid-${changeRequest.subscription.id}`,
body
)
logger.debug(
{ subscriptionId: changeRequest.subscription.id, changeId: change.id },
'created subscription change'
)
} catch (err) {
if (err instanceof recurly.errors.ValidationError) {
/**
* @type {{params?: { param?: string }[] | null}}
*/
const validationError = err
if (
validationError.params?.some(
p => p.param === 'subtotal_amount_in_cents'
)
) {
throw new SubtotalLimitExceededError(
'Subtotal amount in cents exceeded error',
{
subscriptionId: changeRequest.subscription.id,
}
)
}
}
throw err
}
}
/**
@@ -134,14 +160,38 @@ async function applySubscriptionChangeRequest(changeRequest) {
*/
async function previewSubscriptionChange(changeRequest) {
const body = subscriptionChangeRequestToApi(changeRequest)
const subscriptionChange = await client.previewSubscriptionChange(
`uuid-${changeRequest.subscription.id}`,
body
)
return subscriptionChangeFromApi(
changeRequest.subscription,
subscriptionChange
)
try {
const subscriptionChange = await client.previewSubscriptionChange(
`uuid-${changeRequest.subscription.id}`,
body
)
return subscriptionChangeFromApi(
changeRequest.subscription,
subscriptionChange
)
} catch (err) {
if (err instanceof recurly.errors.ValidationError) {
/**
* @type {{params?: { param?: string }[] | null}}
*/
const validationError = err
if (
validationError.params?.some(
p => p.param === 'subtotal_amount_in_cents'
)
) {
throw new SubtotalLimitExceededError(
'Subtotal amount in cents exceeded error',
{
subscriptionId: changeRequest.subscription.id,
}
)
}
}
throw err
}
}
async function removeSubscriptionChange(subscriptionId) {
@@ -18,6 +18,7 @@ import {
ManuallyCollectedError,
PendingChangeError,
InactiveError,
SubtotalLimitExceededError,
} from './Errors.js'
import RecurlyClient from './RecurlyClient.js'
@@ -205,6 +206,13 @@ async function previewAddSeatsSubscriptionChange(req, res) {
return res.status(422).end()
}
if (error instanceof SubtotalLimitExceededError) {
return res.status(422).json({
code: 'subtotal_limit_exceeded',
adding: req.body.adding,
})
}
logger.err(
{ error },
'error trying to preview "add seats" subscription change'
@@ -239,6 +247,13 @@ async function createAddSeatsSubscriptionChange(req, res) {
return res.status(422).end()
}
if (error instanceof SubtotalLimitExceededError) {
return res.status(422).json({
code: 'subtotal_limit_exceeded',
adding: req.body.adding,
})
}
logger.err(
{ error },
'error trying to create "add seats" subscription change'
@@ -313,6 +328,10 @@ async function subscriptionUpgradePage(req, res) {
)
}
if (error instanceof SubtotalLimitExceededError) {
return res.redirect('/user/subscription/group/subtotal-limit-exceeded')
}
if (error instanceof PendingChangeError || error instanceof InactiveError) {
return res.redirect('/user/subscription')
}
@@ -370,6 +389,21 @@ async function manuallyCollectedSubscription(req, res) {
}
}
async function subtotalLimitExceeded(req, res) {
try {
const userId = SessionManager.getLoggedInUserId(req.session)
const subscription =
await SubscriptionLocator.promises.getUsersSubscription(userId)
res.render('subscriptions/subtotal-limit-exceeded', {
groupName: subscription.teamName,
})
} catch (error) {
logger.err({ error }, 'error trying to render subtotal limit exceeded page')
return res.render('/user/subscription')
}
}
export default {
removeUserFromGroup: expressify(removeUserFromGroup),
removeSelfFromGroup: expressify(removeSelfFromGroup),
@@ -386,4 +420,5 @@ export default {
upgradeSubscription: expressify(upgradeSubscription),
missingBillingInformation: expressify(missingBillingInformation),
manuallyCollectedSubscription: expressify(manuallyCollectedSubscription),
subtotalLimitExceeded: expressify(subtotalLimitExceeded),
}
@@ -135,6 +135,14 @@ export default {
SubscriptionGroupController.manuallyCollectedSubscription
)
webRouter.get(
'/user/subscription/group/subtotal-limit-exceeded',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
SubscriptionGroupController.flexibleLicensingSplitTest,
SubscriptionGroupController.subtotalLimitExceeded
)
// Team invites
webRouter.get(
'/subscription/invites/:token/',
@@ -0,0 +1,13 @@
extends ../layout-marketing
block vars
- bootstrap5PageStatus = 'enabled' // Enforce BS5 version
block entrypointVar
- entrypoint = 'pages/user/subscription/group-management/subtotal-limit-exceeded'
block append meta
meta(name="ol-groupName", data-type="string", content=groupName)
block content
main.content.content-alt#subtotal-limit-exceeded-root
@@ -1539,6 +1539,8 @@
"somthing_went_wrong_compiling": "",
"sorry_it_looks_like_that_didnt_work_this_time": "",
"sorry_there_are_no_experiments": "",
"sorry_there_was_an_issue_adding_x_users_to_your_subscription": "",
"sorry_there_was_an_issue_upgrading_your_subscription": "",
"sorry_your_table_cant_be_displayed_at_the_moment": "",
"sort_by": "",
"sort_by_x": "",
@@ -20,7 +20,7 @@ import CostSummary from '@/features/group-management/components/add-seats/cost-s
import RequestStatus from '@/features/group-management/components/request-status'
import useAsync from '@/shared/hooks/use-async'
import getMeta from '@/utils/meta'
import { postJSON } from '@/infrastructure/fetch-json'
import { FetchError, postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import * as yup from 'yup'
import {
@@ -54,7 +54,8 @@ function AddSeats() {
runAsync: runAsyncCostSummary,
data: costSummaryData,
reset: resetCostSummaryData,
} = useAsync<CostSummaryData>()
error: errorCostSummary,
} = useAsync<CostSummaryData, FetchError>()
const {
isLoading: isAddingSeats,
isError: isErrorAddingSeats,
@@ -326,6 +327,7 @@ function AddSeats() {
<CostSummarySection
isLoadingCostSummary={isLoadingCostSummary}
isErrorCostSummary={isErrorCostSummary}
errorCostSummary={errorCostSummary}
shouldContactSales={shouldContactSales}
costSummaryData={costSummaryData}
totalLicenses={totalLicenses}
@@ -379,6 +381,7 @@ function AddSeats() {
type CostSummarySectionProps = {
isLoadingCostSummary: boolean
isErrorCostSummary: boolean
errorCostSummary: Nullable<FetchError>
shouldContactSales: boolean
costSummaryData: Nullable<CostSummaryData>
totalLicenses: number
@@ -387,6 +390,7 @@ type CostSummarySectionProps = {
function CostSummarySection({
isLoadingCostSummary,
isErrorCostSummary,
errorCostSummary,
shouldContactSales,
costSummaryData,
totalLicenses,
@@ -416,6 +420,24 @@ function CostSummarySection({
}
if (isErrorCostSummary) {
if (errorCostSummary?.data?.code === 'subtotal_limit_exceeded') {
return (
<Notification
type="error"
content={
<Trans
i18nKey="sorry_there_was_an_issue_adding_x_users_to_your_subscription"
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
components={[<a href="/contact" rel="noreferrer noopener" />]}
values={{ count: errorCostSummary?.data?.adding }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
}
/>
)
}
return (
<Notification type="error" content={t('generic_something_went_wrong')} />
)
@@ -0,0 +1,25 @@
import { Trans } from 'react-i18next'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import Card from '@/features/group-management/components/card'
function SubtotalLimitExceeded() {
return (
<Card>
<OLNotification
type="error"
content={
<Trans
i18nKey="sorry_there_was_an_issue_upgrading_your_subscription"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
<a href="/contact" rel="noreferrer noopener" />,
]}
/>
}
className="m-0"
/>
</Card>
)
}
export default SubtotalLimitExceeded
@@ -0,0 +1,8 @@
import '../base'
import ReactDOM from 'react-dom'
import SubtotalLimitExceeded from '@/features/group-management/components/subtotal-limit-exceeded'
const element = document.getElementById('subtotal-limit-exceeded-root')
if (element) {
ReactDOM.render(<SubtotalLimitExceeded />, element)
}
+2
View File
@@ -2016,6 +2016,8 @@
"sorry_it_looks_like_that_didnt_work_this_time": "Sorry! It looks like that didnt work this time. Please try again.",
"sorry_something_went_wrong_opening_the_document_please_try_again": "Sorry, an unexpected error occurred when trying to open this content on Overleaf. Please try again.",
"sorry_there_are_no_experiments": "Sorry, there are no experiments currently running in Overleaf Labs.",
"sorry_there_was_an_issue_adding_x_users_to_your_subscription": "Sorry, there was an issue adding __count__ users to your subscription. Please <0>contact our Support team</0> for help.",
"sorry_there_was_an_issue_upgrading_your_subscription": "Sorry, there was an issue upgrading your subscription. Please <0>contact our Support team</0> for help.",
"sorry_this_account_has_been_suspended": "Sorry, this account has been suspended.",
"sorry_your_table_cant_be_displayed_at_the_moment": "Sorry, your table cant be displayed at the moment.",
"sorry_your_token_expired": "Sorry, your token expired",
@@ -0,0 +1,24 @@
import '../../../helpers/bootstrap-5'
import SubtotalLimitExceeded from '@/features/group-management/components/subtotal-limit-exceeded'
describe('<SubtotalLimitExceeded />', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
})
cy.mount(<SubtotalLimitExceeded />)
})
it('shows subtotal limit exceeded notification', function () {
cy.findByRole('alert').within(() => {
cy.findByText(
/sorry, there was an issue upgrading your subscription\. Please.*for help/i
).within(() => {
cy.findByRole('link', {
name: /contact our support team/i,
}).should('have.attr', 'href', '/contact')
})
})
})
})
@@ -111,6 +111,7 @@ describe('RecurlyClient', function () {
}
this.Errors = {
MissingBillingInfoError: class extends Error {},
SubtotalLimitExceededError: class extends Error {},
}
return (this.RecurlyClient = SandboxedModule.require(MODULE_PATH, {
@@ -308,6 +309,32 @@ describe('RecurlyClient', function () {
})
).to.eventually.be.rejectedWith(Error)
})
it('should throw SubtotalLimitExceededError', async function () {
class ValidationError extends recurly.errors.ValidationError {
constructor() {
super()
this.params = [{ param: 'subtotal_amount_in_cents' }]
}
}
this.client.createSubscriptionChange = sinon
.stub()
.throws(new ValidationError())
await expect(
this.RecurlyClient.promises.applySubscriptionChangeRequest({
subscription: this.subscription,
})
).to.be.rejectedWith(this.Errors.SubtotalLimitExceededError)
})
it('should rethrow errors different than SubtotalLimitExceededError', async function () {
this.client.createSubscriptionChange = sinon.stub().throws(new Error())
await expect(
this.RecurlyClient.promises.applySubscriptionChangeRequest({
subscription: this.subscription,
})
).to.be.rejectedWith(Error)
})
})
describe('removeSubscriptionChange', function () {
@@ -467,6 +494,40 @@ describe('RecurlyClient', function () {
expect(immediateCharge.total).to.be.equal(96.2)
})
})
it('should throw SubtotalLimitExceededError', async function () {
class ValidationError extends recurly.errors.ValidationError {
constructor() {
super()
this.params = [{ param: 'subtotal_amount_in_cents' }]
}
}
this.client.previewSubscriptionChange = sinon
.stub()
.throws(new ValidationError())
await expect(
this.RecurlyClient.promises.previewSubscriptionChange(
new RecurlySubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'new-plan',
})
)
).to.be.rejectedWith(this.Errors.SubtotalLimitExceededError)
})
it('should rethrow errors different than SubtotalLimitExceededError', async function () {
this.client.previewSubscriptionChange = sinon.stub().throws(new Error())
await expect(
this.RecurlyClient.promises.previewSubscriptionChange(
new RecurlySubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
planCode: 'new-plan',
})
)
).to.be.rejectedWith(Error)
})
})
describe('getPaymentMethod', function () {
@@ -128,6 +128,7 @@ describe('SubscriptionGroupController', function () {
ManuallyCollectedError: class extends Error {},
PendingChangeError: class extends Error {},
InactiveError: class extends Error {},
SubtotalLimitExceededError: class extends Error {},
}
this.Controller = await esmock.strict(modulePath, {
@@ -495,6 +496,30 @@ describe('SubscriptionGroupController', function () {
this.Controller.previewAddSeatsSubscriptionChange(this.req, res)
})
it('should fail previewing "add seats" change with SubtotalLimitExceededError', function (done) {
this.req.body = { adding: 2 }
this.SubscriptionGroupHandler.promises.previewAddSeatsSubscriptionChange =
sinon.stub().throws(new this.Errors.SubtotalLimitExceededError())
const res = {
status: statusCode => {
statusCode.should.equal(422)
return {
json: data => {
data.should.deep.equal({
code: 'subtotal_limit_exceeded',
adding: this.req.body.adding,
})
done()
},
}
},
}
this.Controller.previewAddSeatsSubscriptionChange(this.req, res)
})
})
describe('createAddSeatsSubscriptionChange', function () {
@@ -532,6 +557,30 @@ describe('SubscriptionGroupController', function () {
this.Controller.createAddSeatsSubscriptionChange(this.req, res)
})
it('should fail applying "add seats" change with SubtotalLimitExceededError', function (done) {
this.req.body = { adding: 2 }
this.SubscriptionGroupHandler.promises.createAddSeatsSubscriptionChange =
sinon.stub().throws(new this.Errors.SubtotalLimitExceededError())
const res = {
status: statusCode => {
statusCode.should.equal(422)
return {
json: data => {
data.should.deep.equal({
code: 'subtotal_limit_exceeded',
adding: this.req.body.adding,
})
done()
},
}
},
}
this.Controller.createAddSeatsSubscriptionChange(this.req, res)
})
})
describe('submitForm', function () {
@@ -666,6 +715,21 @@ describe('SubscriptionGroupController', function () {
this.Controller.subscriptionUpgradePage(this.req, res)
})
it('should redirect to subtotal limit exceeded page', function (done) {
this.SubscriptionGroupHandler.promises.getGroupPlanUpgradePreview = sinon
.stub()
.throws(new this.Errors.SubtotalLimitExceededError())
const res = {
redirect: url => {
url.should.equal('/user/subscription/group/subtotal-limit-exceeded')
done()
},
}
this.Controller.subscriptionUpgradePage(this.req, res)
})
})
describe('upgradeSubscription', function () {