From 31944347673cb3d6801cec229da53975ca5f5b57 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 14 Nov 2025 11:37:32 +0100 Subject: [PATCH] [web] display all Stripe accounts on admin panel (#29625) * refactor PaymentService.getPaymentProviderAdminUrl to be more useful * display all stripe customer accounts * mv change segment modal to each account * stripeSubscriptionData -> stripeCustomerData GitOrigin-RevId: 4c1a277f5073ee7cb12f4596210ba4f8624451b8 --- .../Subscription/PaymentProviderEntities.mjs | 1 + .../Features/Subscription/RecurlyClient.mjs | 19 -- .../Subscription/SubscriptionHelper.js | 59 ++++++ services/web/frontend/js/utils/meta.ts | 14 +- .../src/Subscription/RecurlyClient.test.mjs | 27 --- .../Subscription/SubscriptionHelperTests.js | 200 ++++++++++++++++++ 6 files changed, 268 insertions(+), 52 deletions(-) diff --git a/services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs b/services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs index 089673c34d..b1af9257f6 100644 --- a/services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs +++ b/services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs @@ -653,6 +653,7 @@ export class PaymentProviderAccount { * @param {boolean} [props.hasPastDueInvoice] * @param {object} [props.metadata] * @param {string} [props.metadata.userId] + * @param {string} [props.metadata.segment] */ constructor(props) { this.code = props.code diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.mjs b/services/web/app/src/Features/Subscription/RecurlyClient.mjs index b9c6fd1ae0..d983fcfa8c 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.mjs +++ b/services/web/app/src/Features/Subscription/RecurlyClient.mjs @@ -799,24 +799,6 @@ async function terminateSubscriptionByUuid(subscriptionUuid) { return subscription } -/** - * Get the Recurly admin dashboard url for a user - * - * @param {string} userId - * @returns {string} - */ -function getCustomerAdminUrlFromUserId(userId) { - const isStagOrDev = - Settings.siteUrl.includes('dev-overleaf') || - Settings.siteUrl.includes('stag-overleaf') - - if (isStagOrDev) { - return `https://sharelatex-sandbox.recurly.com/accounts/${userId}` - } - - return `https://sharelatex.recurly.com/accounts/${userId}` -} - export default { errors: recurly.errors, @@ -841,7 +823,6 @@ export default { getPastDueInvoices: callbackify(getPastDueInvoices), failInvoice: callbackify(failInvoice), terminateSubscriptionByUuid: callbackify(terminateSubscriptionByUuid), - getCustomerAdminUrlFromUserId, promises: { getSubscription, diff --git a/services/web/app/src/Features/Subscription/SubscriptionHelper.js b/services/web/app/src/Features/Subscription/SubscriptionHelper.js index d65a7d3304..74d982c9c9 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHelper.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHelper.js @@ -1,3 +1,6 @@ +// @ts-check + +const Settings = require('@overleaf/settings') const { formatCurrency } = require('../../util/currency') const GroupPlansData = require('./GroupPlansData') const { isStandaloneAiAddOnPlanCode } = require('./AiHelper') @@ -196,11 +199,67 @@ function isInTrial(trialEndsAt) { return trialEndsAt.getTime() > Date.now() } +/** + * Get the Recurly customer admin URL + * @param {string | null} customerId - The customer ID in Recurly + * @returns {string | null} + */ +function getRecurlyCustomerAdminUrl(customerId) { + if (customerId == null) { + return null + } + + const isStagOrDev = + Settings.siteUrl.includes('dev-overleaf') || + Settings.siteUrl.includes('stag-overleaf') + + const baseUrl = isStagOrDev + ? 'https://sharelatex-sandbox.recurly.com' + : 'https://sharelatex.recurly.com' + + return `${baseUrl}/accounts/${customerId}` +} + +/** + * Get the Stripe customer admin URL + * @param {string | null} customerId - The customer ID in Stripe + * @param {string} service - The Stripe service ('stripe-us' or 'stripe-uk') + * @returns {string | null} + */ +function getStripeCustomerAdminUrl(customerId, service) { + if (customerId == null || service == null) { + return null + } + + let accountId = null + if (service === 'stripe-us') { + accountId = Settings.apis.stripeUS?.accountId + } else if (service === 'stripe-uk') { + accountId = Settings.apis.stripeUK?.accountId + } + + if (accountId == null) { + return null + } + + const isStagOrDev = + Settings.siteUrl.includes('dev-overleaf') || + Settings.siteUrl.includes('stag-overleaf') + + const baseUrl = isStagOrDev + ? `https://dashboard.stripe.com/${accountId}/test` + : `https://dashboard.stripe.com/${accountId}` + + return `${baseUrl}/customers/${customerId}` +} + module.exports = { shouldPlanChangeAtTermEnd, generateInitialLocalizedGroupPrice, isPaidSubscription, isIndividualActivePaidSubscription, + getRecurlyCustomerAdminUrl, + getStripeCustomerAdminUrl, getPaymentProviderSubscriptionId, getPaidSubscriptionState, getSubscriptionTrialStartedAt, diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index fd67855a8b..c5d8a5bca8 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -275,15 +275,17 @@ export interface Meta { annual?: string monthlyTimesTwelve?: string } - 'ol-stripeAccountId': string - 'ol-stripePublicKeyUK': string - 'ol-stripePublicKeyUS': string - 'ol-stripeSubscriptionData': { + 'ol-stripeCustomerData': Array<{ customerId: string + subscriptionId: string subscriptionState: string | null paymentProviderService: StripePaymentProviderService | null - segment: string | null - } + managementUrl: string + segment?: string | null + error?: string + }> + 'ol-stripePublicKeyUK': string + 'ol-stripePublicKeyUS': string 'ol-subscription': any // TODO: mixed types, split into two fields 'ol-subscriptionChangePreview': SubscriptionChangePreview 'ol-subscriptionCreationPreview': SubscriptionCreationPreview diff --git a/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs b/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs index d01acc8a87..ec538a8c0f 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs +++ b/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs @@ -789,31 +789,4 @@ describe('RecurlyClient', function () { expect(invoices).to.deep.equal(pastDueInvoices) }) }) - - describe('getCustomerAdminUrlFromUserId', function () { - it('should return staging URL for dev-overleaf sites', async function (ctx) { - ctx.settings.siteUrl = 'https://dev-overleaf.example.com' - const userId = 'user-123' - const url = await ctx.RecurlyClient.getCustomerAdminUrlFromUserId(userId) - expect(url).to.equal( - 'https://sharelatex-sandbox.recurly.com/accounts/user-123' - ) - }) - - it('should return staging URL for stag-overleaf sites', async function (ctx) { - ctx.settings.siteUrl = 'https://stag-overleaf.example.com' - const userId = 'user-456' - const url = await ctx.RecurlyClient.getCustomerAdminUrlFromUserId(userId) - expect(url).to.equal( - 'https://sharelatex-sandbox.recurly.com/accounts/user-456' - ) - }) - - it('should return production URL for production sites', async function (ctx) { - ctx.settings.siteUrl = 'https://www.overleaf.com' - const userId = 'user-789' - const url = await ctx.RecurlyClient.getCustomerAdminUrlFromUserId(userId) - expect(url).to.equal('https://sharelatex.recurly.com/accounts/user-789') - }) - }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js index fae3e6e2e6..3257d5812b 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionHelperTests.js @@ -692,4 +692,204 @@ describe('SubscriptionHelper', function () { }) }) }) + + describe('getRecurlyCustomerAdminUrl', function () { + beforeEach(function () { + this.settings.siteUrl = 'https://www.overleaf.com' + }) + + it('should return production Recurly account URL', function () { + const result = + this.SubscriptionHelper.getRecurlyCustomerAdminUrl('user_789') + expect(result).to.equal( + 'https://sharelatex.recurly.com/accounts/user_789' + ) + }) + + it('should return sandbox Recurly account URL for dev environment', function () { + this.settings.siteUrl = 'https://dev-overleaf.com' + const result = + this.SubscriptionHelper.getRecurlyCustomerAdminUrl('user_789') + expect(result).to.equal( + 'https://sharelatex-sandbox.recurly.com/accounts/user_789' + ) + }) + + it('should return sandbox Recurly account URL for staging environment', function () { + this.settings.siteUrl = 'https://stag-overleaf.com' + const result = + this.SubscriptionHelper.getRecurlyCustomerAdminUrl('user_789') + expect(result).to.equal( + 'https://sharelatex-sandbox.recurly.com/accounts/user_789' + ) + }) + + it('should return null if customerId is null', function () { + const result = this.SubscriptionHelper.getRecurlyCustomerAdminUrl(null) + expect(result).to.be.null + }) + + it('should return null if customerId is undefined', function () { + const result = + this.SubscriptionHelper.getRecurlyCustomerAdminUrl(undefined) + expect(result).to.be.null + }) + + it('should handle empty string customerId', function () { + const result = this.SubscriptionHelper.getRecurlyCustomerAdminUrl('') + expect(result).to.equal('https://sharelatex.recurly.com/accounts/') + }) + }) + + describe('getStripeCustomerAdminUrl', function () { + beforeEach(function () { + this.settings.siteUrl = 'https://www.overleaf.com' + this.settings.apis = { + stripeUS: { accountId: 'acct_us_123' }, + stripeUK: { accountId: 'acct_uk_456' }, + } + }) + + describe('stripe-us', function () { + it('should return production Stripe US customer URL', function () { + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + 'cus_us_789', + 'stripe-us' + ) + expect(result).to.equal( + 'https://dashboard.stripe.com/acct_us_123/customers/cus_us_789' + ) + }) + + it('should return test Stripe US customer URL for dev environment', function () { + this.settings.siteUrl = 'https://dev-overleaf.com' + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + 'cus_us_789', + 'stripe-us' + ) + expect(result).to.equal( + 'https://dashboard.stripe.com/acct_us_123/test/customers/cus_us_789' + ) + }) + + it('should return test Stripe US customer URL for staging environment', function () { + this.settings.siteUrl = 'https://stag-overleaf.com' + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + 'cus_us_789', + 'stripe-us' + ) + expect(result).to.equal( + 'https://dashboard.stripe.com/acct_us_123/test/customers/cus_us_789' + ) + }) + }) + + describe('stripe-uk', function () { + it('should return production Stripe UK customer URL', function () { + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + 'cus_uk_123', + 'stripe-uk' + ) + expect(result).to.equal( + 'https://dashboard.stripe.com/acct_uk_456/customers/cus_uk_123' + ) + }) + + it('should return test Stripe UK customer URL for dev environment', function () { + this.settings.siteUrl = 'https://dev-overleaf.com' + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + 'cus_uk_123', + 'stripe-uk' + ) + expect(result).to.equal( + 'https://dashboard.stripe.com/acct_uk_456/test/customers/cus_uk_123' + ) + }) + }) + + it('should return null if accountId is missing', function () { + this.settings.apis.stripeUS = {} + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + 'cus_us_789', + 'stripe-us' + ) + expect(result).to.be.null + }) + + it('should return null if customerId is null', function () { + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + null, + 'stripe-us' + ) + expect(result).to.be.null + }) + + it('should return null if service is null', function () { + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + 'cus_us_789', + null + ) + expect(result).to.be.null + }) + + it('should return null if customerId is undefined', function () { + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + undefined, + 'stripe-us' + ) + expect(result).to.be.null + }) + + it('should return null if service is undefined', function () { + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + 'cus_us_789', + undefined + ) + expect(result).to.be.null + }) + + it('should return null if both customerId and service are null', function () { + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + null, + null + ) + expect(result).to.be.null + }) + + it('should return null if accountId is missing for UK', function () { + this.settings.apis.stripeUK = {} + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + 'cus_uk_789', + 'stripe-uk' + ) + expect(result).to.be.null + }) + + it('should return null if apis object is missing', function () { + this.settings.apis = {} + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + 'cus_us_789', + 'stripe-us' + ) + expect(result).to.be.null + }) + + it('should handle empty string customerId', function () { + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + '', + 'stripe-us' + ) + expect(result).to.equal( + 'https://dashboard.stripe.com/acct_us_123/customers/' + ) + }) + + it('should return null if service is not stripe-us or stripe-uk', function () { + const result = this.SubscriptionHelper.getStripeCustomerAdminUrl( + 'cus_us_789', + 'some-other-service' + ) + expect(result).to.be.null + }) + }) })