mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Merge pull request #23203 from overleaf/ii-flexible-group-licensing-no-billing-details
[web] FL handle subscriptions with missing billing info GitOrigin-RevId: 34209299c039992a80da5739e086beb5d0ede7b0
This commit is contained in:
@@ -14,8 +14,11 @@ class DuplicateAddOnError extends OError {}
|
||||
|
||||
class AddOnNotPresentError extends OError {}
|
||||
|
||||
class MissingBillingInfoError extends OError {}
|
||||
|
||||
module.exports = {
|
||||
RecurlyTransactionError,
|
||||
DuplicateAddOnError,
|
||||
AddOnNotPresentError,
|
||||
MissingBillingInfoError,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const {
|
||||
RecurlyPlan,
|
||||
RecurlyImmediateCharge,
|
||||
} = require('./RecurlyEntities')
|
||||
const { MissingBillingInfoError } = require('./Errors')
|
||||
|
||||
/**
|
||||
* @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
|
||||
@@ -193,7 +194,19 @@ async function resumeSubscriptionByUuid(subscriptionUuid) {
|
||||
* @return {Promise<PaymentMethod>}
|
||||
*/
|
||||
async function getPaymentMethod(userId) {
|
||||
const billingInfo = await client.getBillingInfo(`code-${userId}`)
|
||||
let billingInfo
|
||||
|
||||
try {
|
||||
billingInfo = await client.getBillingInfo(`code-${userId}`)
|
||||
} catch (error) {
|
||||
if (error instanceof recurly.errors.NotFoundError) {
|
||||
throw new MissingBillingInfoError('This account has no billing info', {
|
||||
userId,
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return paymentMethodFromApi(billingInfo)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import ErrorController from '../Errors/ErrorController.js'
|
||||
import UserGetter from '../User/UserGetter.js'
|
||||
import { Subscription } from '../../models/Subscription.js'
|
||||
import { isProfessionalGroupPlan } from './PlansHelper.mjs'
|
||||
import { MissingBillingInfoError } from './Errors.js'
|
||||
import RecurlyClient from './RecurlyClient.js'
|
||||
|
||||
/**
|
||||
* @import { Subscription } from "../../../../types/subscription/dashboard/subscription.js"
|
||||
@@ -129,6 +131,8 @@ async function addSeatsToGroupSubscription(req, res) {
|
||||
userId
|
||||
)
|
||||
await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan)
|
||||
// Check if the user has missing billing details
|
||||
await RecurlyClient.promises.getPaymentMethod(userId)
|
||||
|
||||
res.render('subscriptions/add-seats', {
|
||||
subscriptionId: subscription._id,
|
||||
@@ -141,6 +145,13 @@ async function addSeatsToGroupSubscription(req, res) {
|
||||
{ error },
|
||||
'error while getting users group subscription details'
|
||||
)
|
||||
|
||||
if (error instanceof MissingBillingInfoError) {
|
||||
return res.redirect(
|
||||
'/user/subscription/group/missing-billing-information'
|
||||
)
|
||||
}
|
||||
|
||||
return res.redirect('/user/subscription')
|
||||
}
|
||||
}
|
||||
@@ -247,6 +258,13 @@ async function subscriptionUpgradePage(req, res) {
|
||||
})
|
||||
} catch (error) {
|
||||
logger.err({ error }, 'error loading upgrade subscription page')
|
||||
|
||||
if (error instanceof MissingBillingInfoError) {
|
||||
return res.redirect(
|
||||
'/user/subscription/group/missing-billing-information'
|
||||
)
|
||||
}
|
||||
|
||||
return res.redirect('/user/subscription')
|
||||
}
|
||||
}
|
||||
@@ -262,6 +280,24 @@ async function upgradeSubscription(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
async function missingBillingInformation(req, res) {
|
||||
try {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const subscription =
|
||||
await SubscriptionLocator.promises.getUsersSubscription(userId)
|
||||
|
||||
res.render('subscriptions/missing-billing-information', {
|
||||
groupName: subscription.teamName,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.err(
|
||||
{ error },
|
||||
'error trying to render missing billing information page'
|
||||
)
|
||||
return res.render('/user/subscription')
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
removeUserFromGroup: expressify(removeUserFromGroup),
|
||||
removeSelfFromGroup: expressify(removeSelfFromGroup),
|
||||
@@ -276,4 +312,5 @@ export default {
|
||||
),
|
||||
subscriptionUpgradePage: expressify(subscriptionUpgradePage),
|
||||
upgradeSubscription: expressify(upgradeSubscription),
|
||||
missingBillingInformation: expressify(missingBillingInformation),
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ async function getUsersGroupSubscriptionDetails(userId) {
|
||||
)
|
||||
|
||||
return {
|
||||
userId,
|
||||
subscription,
|
||||
recurlySubscription,
|
||||
plan,
|
||||
|
||||
@@ -119,6 +119,14 @@ export default {
|
||||
SubscriptionGroupController.upgradeSubscription
|
||||
)
|
||||
|
||||
webRouter.get(
|
||||
'/user/subscription/group/missing-billing-information',
|
||||
AuthenticationController.requireLogin(),
|
||||
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
|
||||
SubscriptionGroupController.flexibleLicensingSplitTest,
|
||||
SubscriptionGroupController.missingBillingInformation
|
||||
)
|
||||
|
||||
// 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/missing-billing-information'
|
||||
|
||||
block append meta
|
||||
meta(name="ol-groupName", data-type="string", content=groupName)
|
||||
|
||||
block content
|
||||
main.content.content-alt#missing-billing-information-root
|
||||
@@ -794,6 +794,7 @@
|
||||
"is_email_affiliated": "",
|
||||
"issued_on": "",
|
||||
"it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "",
|
||||
"it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "",
|
||||
"join_beta_program": "",
|
||||
"join_now": "",
|
||||
"join_overleaf_labs": "",
|
||||
@@ -951,6 +952,7 @@
|
||||
"message_received": "",
|
||||
"missing_field_for_entry": "",
|
||||
"missing_fields_for_entry": "",
|
||||
"missing_payment_details": "",
|
||||
"money_back_guarantee": "",
|
||||
"month": "",
|
||||
"month_plural": "",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Card, CardBody, Row, Col } from 'react-bootstrap-5'
|
||||
import getMeta from '@/utils/meta'
|
||||
import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
|
||||
function MissingBillingInformation() {
|
||||
const { t } = useTranslation()
|
||||
const groupName = getMeta('ol-groupName')
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<Row>
|
||||
<Col xl={{ span: 4, offset: 4 }} md={{ span: 6, offset: 3 }}>
|
||||
<div className="group-heading" data-testid="group-heading">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
href="/user/subscription"
|
||||
size="lg"
|
||||
icon="arrow_back"
|
||||
accessibilityLabel={t('back_to_subscription')}
|
||||
/>
|
||||
<h2>{groupName || t('group_subscription')}</h2>
|
||||
</div>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<OLNotification
|
||||
type="error"
|
||||
title={t('missing_payment_details')}
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="it_looks_like_your_payment_details_are_missing_please_update_your_billing_information"
|
||||
components={[
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
|
||||
<a href="/user/subscription" rel="noreferrer noopener" />,
|
||||
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
|
||||
<a href="/contact" rel="noreferrer noopener" />,
|
||||
]}
|
||||
/>
|
||||
}
|
||||
className="m-0"
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MissingBillingInformation
|
||||
@@ -0,0 +1,8 @@
|
||||
import '../base'
|
||||
import ReactDOM from 'react-dom'
|
||||
import MissingBillingInformation from '@/features/group-management/components/missing-billing-information'
|
||||
|
||||
const element = document.getElementById('missing-billing-information-root')
|
||||
if (element) {
|
||||
ReactDOM.render(<MissingBillingInformation />, element)
|
||||
}
|
||||
@@ -1052,6 +1052,7 @@
|
||||
"issued_on": "Issued: __date__",
|
||||
"it": "Italian",
|
||||
"it_looks_like_that_didnt_work_you_can_try_again_or_get_in_touch": "It looks like that didn’t work. You can try again or <0>get in touch</0> with our Support team for more help.",
|
||||
"it_looks_like_your_payment_details_are_missing_please_update_your_billing_information": "It looks like your payment details are missing. Please <0>update your billing information</0>, or <1>get in touch</1> with our Support team for more help.",
|
||||
"ja": "Japanese",
|
||||
"january": "January",
|
||||
"join_beta_program": "Join beta program",
|
||||
@@ -1270,6 +1271,7 @@
|
||||
"message_received": "Message received",
|
||||
"missing_field_for_entry": "Missing field for",
|
||||
"missing_fields_for_entry": "Missing fields for",
|
||||
"missing_payment_details": "Missing payment details",
|
||||
"money_back_guarantee": "30-day money back guarantee, no questions asked",
|
||||
"month": "month",
|
||||
"month_plural": "months",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import '../../../helpers/bootstrap-5'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
import MissingBillingInformation from '@/features/group-management/components/missing-billing-information'
|
||||
|
||||
describe('<MissingBillingInformation />', function () {
|
||||
beforeEach(function () {
|
||||
cy.window().then(win => {
|
||||
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
|
||||
})
|
||||
|
||||
cy.mount(
|
||||
<SplitTestProvider>
|
||||
<MissingBillingInformation />
|
||||
</SplitTestProvider>
|
||||
)
|
||||
})
|
||||
|
||||
it('shows missing payment details notification', function () {
|
||||
cy.findByRole('alert').within(() => {
|
||||
cy.findByText(/missing payment details/i)
|
||||
cy.findByText(
|
||||
/it looks like your payment details are missing\. Please.*, or.*with our Support team for more help/i
|
||||
).within(() => {
|
||||
cy.findByRole('link', {
|
||||
name: /update your billing information/i,
|
||||
}).should('have.attr', 'href', '/user/subscription')
|
||||
cy.findByRole('link', { name: /get in touch/i }).should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'/contact'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -99,6 +99,7 @@ describe('RecurlyClient', function () {
|
||||
let client
|
||||
this.client = client = {
|
||||
getAccount: sinon.stub(),
|
||||
getBillingInfo: sinon.stub(),
|
||||
listAccountSubscriptions: sinon.stub(),
|
||||
previewSubscriptionChange: sinon.stub(),
|
||||
}
|
||||
@@ -108,6 +109,9 @@ describe('RecurlyClient', function () {
|
||||
return client
|
||||
},
|
||||
}
|
||||
this.Errors = {
|
||||
MissingBillingInfoError: class MissingBillingInfoError extends Error {},
|
||||
}
|
||||
|
||||
return (this.RecurlyClient = SandboxedModule.require(MODULE_PATH, {
|
||||
globals: {
|
||||
@@ -124,6 +128,7 @@ describe('RecurlyClient', function () {
|
||||
debug: sinon.stub(),
|
||||
},
|
||||
'../User/UserGetter': this.UserGetter,
|
||||
'./Errors': this.Errors,
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -463,4 +468,22 @@ describe('RecurlyClient', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPaymentMethod', function () {
|
||||
it('should throw MissingBillingInfoError', async function () {
|
||||
this.client.getBillingInfo = sinon
|
||||
.stub()
|
||||
.throws(new recurly.errors.NotFoundError())
|
||||
await expect(
|
||||
this.RecurlyClient.promises.getPaymentMethod(this.user._id)
|
||||
).to.be.rejectedWith(this.Errors.MissingBillingInfoError)
|
||||
})
|
||||
|
||||
it('should rethrow errors different than MissingBillingInfoError', async function () {
|
||||
this.client.getBillingInfo = sinon.stub().throws(new Error())
|
||||
await expect(
|
||||
this.RecurlyClient.promises.getPaymentMethod(this.user._id)
|
||||
).to.be.rejectedWith(Error)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,7 +103,13 @@ describe('SubscriptionGroupController', function () {
|
||||
},
|
||||
}
|
||||
|
||||
this.RecurlyClient = {}
|
||||
this.paymentMethod = { cardType: 'Visa', lastFour: '1111' }
|
||||
|
||||
this.RecurlyClient = {
|
||||
promises: {
|
||||
getPaymentMethod: sinon.stub().resolves(this.paymentMethod),
|
||||
},
|
||||
}
|
||||
|
||||
this.SubscriptionController = {}
|
||||
|
||||
@@ -113,6 +119,10 @@ describe('SubscriptionGroupController', function () {
|
||||
isProfessionalGroupPlan: sinon.stub().returns(false),
|
||||
}
|
||||
|
||||
this.Errors = {
|
||||
MissingBillingInfoError: class MissingBillingInfoError extends Error {},
|
||||
}
|
||||
|
||||
this.Controller = await esmock.strict(modulePath, {
|
||||
'../../../../app/src/Features/Subscription/SubscriptionGroupHandler':
|
||||
this.SubscriptionGroupHandler,
|
||||
@@ -135,6 +145,7 @@ describe('SubscriptionGroupController', function () {
|
||||
'../../../../app/src/Features/Subscription/RecurlyClient':
|
||||
this.RecurlyClient,
|
||||
'../../../../app/src/Features/Subscription/PlansHelper': this.PlansHelper,
|
||||
'../../../../app/src/Features/Subscription/Errors': this.Errors,
|
||||
'../../../../app/src/models/Subscription': this.SubscriptionModel,
|
||||
'@overleaf/logger': {
|
||||
err: sinon.stub(),
|
||||
@@ -375,6 +386,23 @@ describe('SubscriptionGroupController', function () {
|
||||
|
||||
this.Controller.addSeatsToGroupSubscription(this.req, res)
|
||||
})
|
||||
|
||||
it('should redirect to missing billing information page when billing information is missing', function (done) {
|
||||
this.RecurlyClient.promises.getPaymentMethod = sinon
|
||||
.stub()
|
||||
.throws(new this.Errors.MissingBillingInfoError())
|
||||
|
||||
const res = {
|
||||
redirect: url => {
|
||||
url.should.equal(
|
||||
'/user/subscription/group/missing-billing-information'
|
||||
)
|
||||
done()
|
||||
},
|
||||
}
|
||||
|
||||
this.Controller.addSeatsToGroupSubscription(this.req, res)
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewAddSeatsSubscriptionChange', function () {
|
||||
@@ -549,6 +577,21 @@ describe('SubscriptionGroupController', function () {
|
||||
|
||||
this.Controller.subscriptionUpgradePage(this.req, res)
|
||||
})
|
||||
|
||||
it('should redirect to missing billing information page when billing information is missing', function (done) {
|
||||
this.RecurlyClient.promises.getPaymentMethod = sinon
|
||||
.stub()
|
||||
.throws(new this.Errors.MissingBillingInfoError())
|
||||
|
||||
const res = {
|
||||
redirect: url => {
|
||||
url.should.equal('/user/subscription')
|
||||
done()
|
||||
},
|
||||
}
|
||||
|
||||
this.Controller.subscriptionUpgradePage(this.req, res)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upgradeSubscription', function () {
|
||||
|
||||
@@ -302,6 +302,7 @@ describe('SubscriptionGroupHandler', function () {
|
||||
)
|
||||
|
||||
expect(data).to.deep.equal({
|
||||
userId: this.adminUser_id,
|
||||
subscription: { groupPlan: true },
|
||||
plan: {
|
||||
membersLimit: 5,
|
||||
|
||||
Reference in New Issue
Block a user