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

[web] add stripe customer portal link

GitOrigin-RevId: 6baaf51d4dd89ef779229ad17603529db06cf396
This commit is contained in:
Kristina
2025-04-24 08:05:44 +00:00
committed by Copybot
parent 6f1f1ba744
commit 6166a51552
13 changed files with 200 additions and 118 deletions
@@ -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,
@@ -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,
@@ -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(),
@@ -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,
},
}
@@ -2000,6 +2000,7 @@
"view_only_downgraded": "",
"view_only_reviewer_downgraded": "",
"view_options": "",
"view_payment_portal": "",
"view_pdf": "",
"view_your_invoices": "",
"viewer": "",
@@ -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
@@ -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} />
@@ -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
View File
@@ -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",
@@ -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',
@@ -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,
@@ -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
@@ -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'
)
})
})
})
})