From 6166a5155210af99c01dbe10f13643d02d56fe67 Mon Sep 17 00:00:00 2001
From: Kristina <7614497+khjrtbrg@users.noreply.github.com>
Date: Wed, 23 Apr 2025 12:13:36 +0200
Subject: [PATCH] Merge pull request #24922 from
overleaf/kh-add-customer-portal-links
[web] add stripe customer portal link
GitOrigin-RevId: 6baaf51d4dd89ef779229ad17603529db06cf396
---
.../Features/Subscription/RecurlyClient.js | 38 +++++++++++++++
.../Subscription/SubscriptionController.js | 13 -----
.../Subscription/SubscriptionRouter.mjs | 7 ---
.../SubscriptionViewModelBuilder.js | 47 +++----------------
.../web/frontend/extracted-translations.json | 1 +
.../missing-billing-information.tsx | 2 +-
.../dashboard/states/active/active-new.tsx | 42 +++++++++++------
.../dashboard/states/active/paused.tsx | 44 ++++++++++-------
services/web/locales/en.json | 1 +
.../missing-billing-information.spec.tsx | 2 +-
.../subscription/fixtures/subscriptions.ts | 44 ++++++++---------
.../src/Subscription/RecurlyClientTests.js | 44 +++++++++++++++++
.../SubscriptionViewModelBuilderTests.js | 33 ++++++++++++-
13 files changed, 200 insertions(+), 118 deletions(-)
diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js
index 51375074fb..e3265c745f 100644
--- a/services/web/app/src/Features/Subscription/RecurlyClient.js
+++ b/services/web/app/src/Features/Subscription/RecurlyClient.js
@@ -100,6 +100,43 @@ async function getActiveCouponsForUserId(userId) {
}
}
+/**
+ * Get hosted customer management link
+ *
+ * @param {string} userId
+ * @param {string} pageType
+ * @return {Promise}
+ */
+async function getCustomerManagementLink(userId, pageType) {
+ try {
+ const account = await client.getAccount(`code-${userId}`)
+ const recurlySubdomain = Settings.apis.recurly.subdomain
+ const hostedLoginToken = account.hostedLoginToken
+ if (!hostedLoginToken) {
+ throw new OError('recurly account does not have hosted login token')
+ }
+ let path = ''
+ if (pageType === 'billing-details') {
+ path = 'billing_info/edit?ht='
+ }
+ return [
+ 'https://',
+ recurlySubdomain,
+ '.recurly.com/account/',
+ path,
+ hostedLoginToken,
+ ].join('')
+ } catch (err) {
+ if (err instanceof recurly.errors.NotFoundError) {
+ // An expected error, we don't need to handle it, just return nothing
+ logger.debug({ userId }, 'no recurly account found for user')
+ return null
+ } else {
+ throw err
+ }
+ }
+}
+
/**
* Get a subscription from Recurly
*
@@ -629,6 +666,7 @@ module.exports = {
getAccountForUserId,
createAccountForUserId,
getActiveCouponsForUserId,
+ getCustomerManagementLink,
previewSubscriptionChange,
applySubscriptionChangeRequest,
removeSubscriptionChange,
diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js
index 838b580273..4cc4c7e9f7 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionController.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionController.js
@@ -611,18 +611,6 @@ async function refreshUserFeatures(req, res) {
res.sendStatus(200)
}
-async function redirectToHostedPage(req, res) {
- const userId = SessionManager.getLoggedInUserId(req.session)
- const { pageType } = req.params
- const url =
- await SubscriptionViewModelBuilder.promises.getRedirectToHostedPage(
- userId,
- pageType
- )
- logger.warn({ userId, pageType }, 'redirecting to recurly hosted page')
- res.redirect(url)
-}
-
async function getRecommendedCurrency(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
let ip = req.ip
@@ -793,7 +781,6 @@ module.exports = {
extendTrial: expressify(extendTrial),
recurlyNotificationParser,
refreshUserFeatures: expressify(refreshUserFeatures),
- redirectToHostedPage: expressify(redirectToHostedPage),
previewAddonPurchase: expressify(previewAddonPurchase),
purchaseAddon,
removeAddon,
diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
index 0bb30b578e..326dbf875c 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
+++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
@@ -54,13 +54,6 @@ export default {
SubscriptionController.canceledSubscription
)
- webRouter.get(
- '/user/subscription/recurly/:pageType',
- AuthenticationController.requireLogin(),
- RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
- SubscriptionController.redirectToHostedPage
- )
-
webRouter.delete(
'/subscription/group/user',
AuthenticationController.requireLogin(),
diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
index 45766dd09c..03c7bd19ce 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js
@@ -17,11 +17,7 @@ const _ = require('lodash')
const async = require('async')
const SubscriptionHelper = require('./SubscriptionHelper')
const { callbackify } = require('@overleaf/promise-utils')
-const {
- InvalidError,
- NotFoundError,
- V1ConnectionError,
-} = require('../Errors/Errors')
+const { V1ConnectionError } = require('../Errors/Errors')
const FeaturesHelper = require('./FeaturesHelper')
const { formatCurrency } = require('../../util/currency')
const Modules = require('../../infrastructure/Modules')
@@ -31,7 +27,7 @@ const Modules = require('../../infrastructure/Modules')
*/
function buildHostedLink(type) {
- return `/user/subscription/recurly/${type}`
+ return `/user/subscription/payment/${type}`
}
// Downgrade from Mongoose object, so we can add custom attributes to object
@@ -41,39 +37,6 @@ function serializeMongooseObject(object) {
: object
}
-async function getRedirectToHostedPage(userId, pageType) {
- if (!['billing-details', 'account-management'].includes(pageType)) {
- throw new InvalidError('unexpected page type')
- }
- const personalSubscription =
- await SubscriptionLocator.promises.getUsersSubscription(userId)
- const recurlySubscriptionId = personalSubscription?.recurlySubscription_id
- if (!recurlySubscriptionId) {
- throw new NotFoundError('not a recurly subscription')
- }
- const recurlySubscription = await RecurlyWrapper.promises.getSubscription(
- recurlySubscriptionId,
- { includeAccount: true }
- )
-
- const recurlySubdomain = Settings.apis.recurly.subdomain
- const hostedLoginToken = recurlySubscription.account.hosted_login_token
- if (!hostedLoginToken) {
- throw new Error('recurly account does not have hosted login token')
- }
- let path = ''
- if (pageType === 'billing-details') {
- path = 'billing_info/edit?ht='
- }
- return [
- 'https://',
- recurlySubdomain,
- '.recurly.com/account/',
- path,
- hostedLoginToken,
- ].join('')
-}
-
async function buildUsersSubscriptionViewModel(user, locale = 'en') {
let {
personalSubscription,
@@ -281,7 +244,10 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
personalSubscription.payment = {
taxRate,
- billingDetailsLink: buildHostedLink('billing-details'),
+ billingDetailsLink:
+ paymentRecord.subscription.service === 'recurly'
+ ? buildHostedLink('billing-details')
+ : null,
accountManagementLink: buildHostedLink('account-management'),
additionalLicenses,
addOns,
@@ -608,7 +574,6 @@ module.exports = {
getBestSubscription: callbackify(getBestSubscription),
promises: {
buildUsersSubscriptionViewModel,
- getRedirectToHostedPage,
getBestSubscription,
},
}
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index c11e77c2d6..d24c74f9ec 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -2000,6 +2000,7 @@
"view_only_downgraded": "",
"view_only_reviewer_downgraded": "",
"view_options": "",
+ "view_payment_portal": "",
"view_pdf": "",
"view_your_invoices": "",
"viewer": "",
diff --git a/services/web/frontend/js/features/group-management/components/missing-billing-information.tsx b/services/web/frontend/js/features/group-management/components/missing-billing-information.tsx
index 499e57f27f..cd8e8ef743 100644
--- a/services/web/frontend/js/features/group-management/components/missing-billing-information.tsx
+++ b/services/web/frontend/js/features/group-management/components/missing-billing-information.tsx
@@ -16,7 +16,7 @@ function MissingBillingInformation() {
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
,
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx
index 22291f499e..2bd8639f6a 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx
@@ -140,21 +140,33 @@ export function ActiveSubscriptionNew({
/>
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/paused.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/paused.tsx
index 44416cc9b2..651a8b1827 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/paused.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/paused.tsx
@@ -66,22 +66,34 @@ export function PausedSubscription({
-
- {t('update_your_billing_details')}
- {' '}
-
- {t('view_your_invoices')}
-
+ {subscription.payment.billingDetailsLink ? (
+ <>
+
+ {t('update_your_billing_details')}
+ {' '}
+
+ {t('view_your_invoices')}
+
+ >
+ ) : (
+
+ {t('view_payment_portal')}
+
+ )}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 069c3fcb20..32b47bdeeb 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -2546,6 +2546,7 @@
"view_only_downgraded": "View only. Upgrade to restore edit access.",
"view_only_reviewer_downgraded": "View only. Upgrade to restore review access.",
"view_options": "View options",
+ "view_payment_portal": "View invoices and billing details",
"view_pdf": "View PDF",
"view_source": "View Source",
"view_your_invoices": "View your invoices",
diff --git a/services/web/test/frontend/features/group-management/components/missing-billing-information.spec.tsx b/services/web/test/frontend/features/group-management/components/missing-billing-information.spec.tsx
index 4eb137c0dc..d9e8de6770 100644
--- a/services/web/test/frontend/features/group-management/components/missing-billing-information.spec.tsx
+++ b/services/web/test/frontend/features/group-management/components/missing-billing-information.spec.tsx
@@ -25,7 +25,7 @@ describe('
', function () {
}).should(
'have.attr',
'href',
- '/user/subscription/recurly/billing-details'
+ '/user/subscription/payment/billing-details'
)
cy.findByRole('link', { name: /get in touch/i }).should(
'have.attr',
diff --git a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
index 3a05c87761..b23a5b1241 100644
--- a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
+++ b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts
@@ -35,8 +35,8 @@ export const annualActiveSubscription: PaidSubscription = {
},
payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -75,8 +75,8 @@ export const annualActiveSubscriptionEuro: PaidSubscription = {
},
payment: {
taxRate: 0.24,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -114,8 +114,8 @@ export const annualActiveSubscriptionPro: PaidSubscription = {
},
payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -154,8 +154,8 @@ export const pastDueExpiredSubscription: PaidSubscription = {
},
payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -194,8 +194,8 @@ export const canceledSubscription: PaidSubscription = {
},
payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -234,8 +234,8 @@ export const pendingSubscriptionChange: PaidSubscription = {
},
payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
@@ -285,8 +285,8 @@ export const groupActiveSubscription: GroupSubscription = {
},
payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 10,
nextPaymentDueAt,
@@ -330,8 +330,8 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
},
payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 11,
totalLicenses: 21,
nextPaymentDueAt,
@@ -382,8 +382,8 @@ export const trialSubscription: PaidSubscription = {
},
payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt: sevenDaysFromTodayFormatted,
@@ -443,8 +443,8 @@ export const trialCollaboratorSubscription: PaidSubscription = {
},
payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt: sevenDaysFromTodayFormatted,
@@ -482,8 +482,8 @@ export const monthlyActiveCollaborator: PaidSubscription = {
},
payment: {
taxRate: 0,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
- accountManagementLink: '/user/subscription/recurly/account-management',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
+ accountManagementLink: '/user/subscription/payment/account-management',
additionalLicenses: 0,
totalLicenses: 0,
nextPaymentDueAt,
diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js
index dde1c53fd7..8fa7392d0c 100644
--- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js
+++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js
@@ -19,6 +19,7 @@ describe('RecurlyClient', function () {
recurly: {
apiKey: 'nonsense',
privateKey: 'private_nonsense',
+ subdomain: 'test',
},
},
plans: [],
@@ -252,6 +253,49 @@ describe('RecurlyClient', function () {
})
})
+ describe('getCustomerManagementLink', function () {
+ it('should throw if recurly token is not returned', async function () {
+ this.client.getAccount.resolves({})
+ await expect(
+ this.RecurlyClient.promises.getCustomerManagementLink(
+ '12345',
+ 'account-management',
+ 'en-US'
+ )
+ ).to.be.rejectedWith('recurly account does not have hosted login token')
+ })
+
+ it('should generate the correct account management url', async function () {
+ this.client.getAccount.resolves({
+ hostedLoginToken: '987654321',
+ })
+ const result =
+ await this.RecurlyClient.promises.getCustomerManagementLink(
+ '12345',
+ 'account-management',
+ 'en-US'
+ )
+
+ expect(result).to.equal('https://test.recurly.com/account/987654321')
+ })
+
+ it('should generate the correct billing details url', async function () {
+ this.client.getAccount.resolves({
+ hostedLoginToken: '987654321',
+ })
+ const result =
+ await this.RecurlyClient.promises.getCustomerManagementLink(
+ '12345',
+ 'billing-details',
+ 'en-US'
+ )
+
+ expect(result).to.equal(
+ 'https://test.recurly.com/account/billing_info/edit?ht=987654321'
+ )
+ })
+ })
+
describe('getSubscription', function () {
it('should return the subscription found by recurly', async function () {
this.client.getSubscription = sinon
diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js
index dbfa775876..90a69f9a89 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js
@@ -531,9 +531,9 @@ describe('SubscriptionViewModelBuilder', function () {
)
assert.deepEqual(result.personalSubscription.payment, {
taxRate: 0.1,
- billingDetailsLink: '/user/subscription/recurly/billing-details',
+ billingDetailsLink: '/user/subscription/payment/billing-details',
accountManagementLink:
- '/user/subscription/recurly/account-management',
+ '/user/subscription/payment/account-management',
additionalLicenses: 0,
addOns: [
{
@@ -625,6 +625,35 @@ describe('SubscriptionViewModelBuilder', function () {
12
)
})
+
+ it('does not add a billing details link for a Stripe subscription', async function () {
+ this.paymentRecord.service = 'stripe'
+ this.SubscriptionLocator.getUsersSubscription.yields(
+ null,
+ this.individualSubscription
+ )
+ this.Modules.hooks.fire
+ .withArgs('getPaymentFromRecord', this.individualSubscription)
+ .yields(null, [
+ {
+ subscription: this.paymentRecord,
+ account: new PaymentProviderAccount({}),
+ coupons: [],
+ },
+ ])
+ const result =
+ await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
+ this.user
+ )
+ assert.equal(
+ result.personalSubscription.payment.billingDetailsLink,
+ undefined
+ )
+ assert.equal(
+ result.personalSubscription.payment.accountManagementLink,
+ '/user/subscription/payment/account-management'
+ )
+ })
})
})
})