mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 06:03:45 +01:00
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:
@@ -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": "",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user