Browse Source

Merge pull request #24922 from overleaf/kh-add-customer-portal-links

[web] add stripe customer portal link

GitOrigin-RevId: 6baaf51d4dd89ef779229ad17603529db06cf396
Kristina 1 year ago
parent
commit
6166a51552

+ 38 - 0
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<string|null>}
+ */
+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,

+ 0 - 13
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,

+ 0 - 7
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(),

+ 6 - 41
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,
   },
 }

+ 1 - 0
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": "",

+ 1 - 1
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
               <a
-                href="/user/subscription/recurly/billing-details"
+                href="/user/subscription/payment/billing-details"
                 rel="noreferrer noopener"
               />,
               // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key

+ 27 - 15
services/web/frontend/js/features/subscription/components/dashboard/states/active/active-new.tsx

@@ -140,21 +140,33 @@ export function ActiveSubscriptionNew({
         />
       </p>
       <div>
-        <a
-          href={subscription.payment.accountManagementLink}
-          target="_blank"
-          rel="noreferrer noopener"
-          className="me-2"
-        >
-          {t('view_invoices')}
-        </a>
-        <a
-          href={subscription.payment.billingDetailsLink}
-          target="_blank"
-          rel="noreferrer noopener"
-        >
-          {t('view_billing_details')}
-        </a>
+        {subscription.payment.billingDetailsLink ? (
+          <>
+            <a
+              href={subscription.payment.accountManagementLink}
+              target="_blank"
+              rel="noreferrer noopener"
+              className="me-2"
+            >
+              {t('view_invoices')}
+            </a>
+            <a
+              href={subscription.payment.billingDetailsLink}
+              target="_blank"
+              rel="noreferrer noopener"
+            >
+              {t('view_billing_details')}
+            </a>
+          </>
+        ) : (
+          <a
+            href={subscription.payment.accountManagementLink}
+            rel="noreferrer noopener"
+            className="me-2"
+          >
+            {t('view_payment_portal')}
+          </a>
+        )}
       </div>
       <div className="mt-3">
         <PriceExceptions subscription={subscription} />

+ 28 - 16
services/web/frontend/js/features/subscription/components/dashboard/states/active/paused.tsx

@@ -66,22 +66,34 @@ export function PausedSubscription({
       </p>
 
       <p className="d-inline-flex flex-wrap gap-1">
-        <a
-          href={subscription.payment.billingDetailsLink}
-          target="_blank"
-          rel="noreferrer noopener"
-          className="btn btn-secondary-info btn-secondary"
-        >
-          {t('update_your_billing_details')}
-        </a>{' '}
-        <a
-          href={subscription.payment.accountManagementLink}
-          target="_blank"
-          rel="noreferrer noopener"
-          className="btn btn-secondary-info btn-secondary"
-        >
-          {t('view_your_invoices')}
-        </a>
+        {subscription.payment.billingDetailsLink ? (
+          <>
+            <a
+              href={subscription.payment.billingDetailsLink}
+              target="_blank"
+              rel="noreferrer noopener"
+              className="btn btn-secondary-info btn-secondary"
+            >
+              {t('update_your_billing_details')}
+            </a>{' '}
+            <a
+              href={subscription.payment.accountManagementLink}
+              target="_blank"
+              rel="noreferrer noopener"
+              className="btn btn-secondary-info btn-secondary"
+            >
+              {t('view_your_invoices')}
+            </a>
+          </>
+        ) : (
+          <a
+            href={subscription.payment.accountManagementLink}
+            rel="noreferrer noopener"
+            className="btn btn-secondary-info btn-secondary"
+          >
+            {t('view_payment_portal')}
+          </a>
+        )}
       </p>
 
       <ChangePlanModal />

+ 1 - 0
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",

+ 1 - 1
services/web/test/frontend/features/group-management/components/missing-billing-information.spec.tsx

@@ -25,7 +25,7 @@ describe('<MissingBillingInformation />', 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',

+ 22 - 22
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,

+ 44 - 0
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

+ 31 - 2
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'
+        )
+      })
     })
   })
 })