From ebc9139a3632551cff9a3097d395f02fbc576d5a 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({ />

- - {t('view_invoices')} - - - {t('view_billing_details')} - + {subscription.payment.billingDetailsLink ? ( + <> + + {t('view_invoices')} + + + {t('view_billing_details')} + + + ) : ( + + {t('view_payment_portal')} + + )}
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' + ) + }) }) }) })