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:
ilkin-overleaf
2025-02-04 13:53:14 +02:00
committed by Copybot
parent 72be034435
commit 16130b79db
14 changed files with 242 additions and 2 deletions

View File

@@ -14,8 +14,11 @@ class DuplicateAddOnError extends OError {}
class AddOnNotPresentError extends OError {}
class MissingBillingInfoError extends OError {}
module.exports = {
RecurlyTransactionError,
DuplicateAddOnError,
AddOnNotPresentError,
MissingBillingInfoError,
}

View File

@@ -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)
}

View File

@@ -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),
}

View File

@@ -81,6 +81,7 @@ async function getUsersGroupSubscriptionDetails(userId) {
)
return {
userId,
subscription,
recurlySubscription,
plan,

View File

@@ -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/',

View File

@@ -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

View File

@@ -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": "",

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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 didnt 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",

View File

@@ -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'
)
})
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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 () {

View File

@@ -302,6 +302,7 @@ describe('SubscriptionGroupHandler', function () {
)
expect(data).to.deep.equal({
userId: this.adminUser_id,
subscription: { groupPlan: true },
plan: {
membersLimit: 5,