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:
@@ -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": "",
|
||||
|
||||
+24
-2
@@ -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')} />
|
||||
)
|
||||
|
||||
+25
@@ -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
|
||||
+8
@@ -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)
|
||||
}
|
||||
@@ -2016,6 +2016,8 @@
|
||||
"sorry_it_looks_like_that_didnt_work_this_time": "Sorry! It looks like that didn’t 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 can’t be displayed at the moment.",
|
||||
"sorry_your_token_expired": "Sorry, your token expired",
|
||||
|
||||
+24
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user