mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #21839 from overleaf/em-subscription-change-preview-premium
Subscription preview for users with standalone AI add-on GitOrigin-RevId: 636fa5aca8538bb95e79040d5c309dc505cfb17a
This commit is contained in:
parent
34fa1e12e7
commit
424fd5b591
12 changed files with 253 additions and 32 deletions
27
package-lock.json
generated
27
package-lock.json
generated
|
@ -41611,6 +41611,7 @@
|
|||
"mocha": "^10.2.0",
|
||||
"mocha-each": "^2.0.1",
|
||||
"mock-fs": "^5.1.2",
|
||||
"nock": "^13.5.6",
|
||||
"nvd3": "^1.8.6",
|
||||
"overleaf-editor-core": "*",
|
||||
"pdfjs-dist": "4.6.82",
|
||||
|
@ -43736,6 +43737,20 @@
|
|||
"@sinonjs/commons": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"services/web/node_modules/nock": {
|
||||
"version": "13.5.6",
|
||||
"resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz",
|
||||
"integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.1.0",
|
||||
"json-stringify-safe": "^5.0.1",
|
||||
"propagate": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
}
|
||||
},
|
||||
"services/web/node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
|
@ -51174,6 +51189,7 @@
|
|||
"mongoose": "8.5.3",
|
||||
"multer": "overleaf/multer#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e",
|
||||
"nocache": "^2.1.0",
|
||||
"nock": "^13.5.6",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.7.0",
|
||||
"nodemailer-ses-transport": "^1.5.1",
|
||||
|
@ -52744,6 +52760,17 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"nock": {
|
||||
"version": "13.5.6",
|
||||
"resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz",
|
||||
"integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "^4.1.0",
|
||||
"json-stringify-safe": "^5.0.1",
|
||||
"propagate": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
|
|
|
@ -13,6 +13,7 @@ const {
|
|||
PaypalPaymentMethod,
|
||||
CreditCardPaymentMethod,
|
||||
RecurlyAddOn,
|
||||
RecurlyPlan,
|
||||
} = require('./RecurlyEntities')
|
||||
|
||||
/**
|
||||
|
@ -67,6 +68,32 @@ async function getSubscription(subscriptionId) {
|
|||
return subscriptionFromApi(subscription)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subscription for a given user
|
||||
*
|
||||
* @param {string} userId
|
||||
* @return {Promise<RecurlySubscription | null>}
|
||||
*/
|
||||
async function getSubscriptionForUser(userId) {
|
||||
const subscriptions = await client.listAccountSubscriptions(
|
||||
`code-${userId}`,
|
||||
{ params: { state: 'active', limit: 2 } }
|
||||
)
|
||||
let result = null
|
||||
for await (const subscription of subscriptions.each()) {
|
||||
if (result != null) {
|
||||
throw new OError('User has more than one Recurly subscription', {
|
||||
userId,
|
||||
})
|
||||
}
|
||||
result = subscription
|
||||
}
|
||||
if (result == null) {
|
||||
return null
|
||||
}
|
||||
return subscriptionFromApi(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a susbcription change from Recurly
|
||||
*
|
||||
|
@ -161,6 +188,17 @@ async function getAddOn(planCode, addOnCode) {
|
|||
return addOnFromApi(addOn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configuration for a given plan
|
||||
*
|
||||
* @param {string} planCode
|
||||
* @return {Promise<RecurlyPlan>}
|
||||
*/
|
||||
async function getPlan(planCode) {
|
||||
const plan = await client.getPlan(`code-${planCode}`)
|
||||
return planFromApi(plan)
|
||||
}
|
||||
|
||||
function subscriptionIsCanceledOrExpired(subscription) {
|
||||
const state = subscription?.recurlyStatus?.state
|
||||
return state === 'canceled' || state === 'expired'
|
||||
|
@ -303,6 +341,22 @@ function addOnFromApi(addOn) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a RecurlyPlan from Recurly API data
|
||||
*
|
||||
* @param {recurly.Plan} plan
|
||||
* @return {RecurlyPlan}
|
||||
*/
|
||||
function planFromApi(plan) {
|
||||
if (plan.code == null || plan.name == null) {
|
||||
throw new OError('Invalid Recurly add-on', { plan })
|
||||
}
|
||||
return new RecurlyPlan({
|
||||
code: plan.code,
|
||||
name: plan.name,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an API request from a RecurlySubscriptionChangeRequest
|
||||
*
|
||||
|
@ -339,6 +393,7 @@ module.exports = {
|
|||
getAccountForUserId: callbackify(getAccountForUserId),
|
||||
createAccountForUserId: callbackify(createAccountForUserId),
|
||||
getSubscription: callbackify(getSubscription),
|
||||
getSubscriptionForUser: callbackify(getSubscriptionForUser),
|
||||
previewSubscriptionChange: callbackify(previewSubscriptionChange),
|
||||
applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
|
||||
removeSubscriptionChange: callbackify(removeSubscriptionChange),
|
||||
|
@ -347,10 +402,12 @@ module.exports = {
|
|||
cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
|
||||
getPaymentMethod: callbackify(getPaymentMethod),
|
||||
getAddOn: callbackify(getAddOn),
|
||||
getPlan: callbackify(getPlan),
|
||||
subscriptionIsCanceledOrExpired,
|
||||
|
||||
promises: {
|
||||
getSubscription,
|
||||
getSubscriptionForUser,
|
||||
getAccountForUserId,
|
||||
createAccountForUserId,
|
||||
previewSubscriptionChange,
|
||||
|
@ -361,5 +418,6 @@ module.exports = {
|
|||
cancelSubscriptionByUuid,
|
||||
getPaymentMethod,
|
||||
getAddOn,
|
||||
getPlan,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ const PlansLocator = require('./PlansLocator')
|
|||
const SubscriptionHelper = require('./SubscriptionHelper')
|
||||
|
||||
const AI_ADD_ON_CODE = 'assistant'
|
||||
const STANDALONE_AI_ADD_ON_CODES = ['assistant', 'assistant-annual']
|
||||
|
||||
class RecurlySubscription {
|
||||
/**
|
||||
|
@ -44,6 +45,15 @@ class RecurlySubscription {
|
|||
return this.addOns.some(addOn => addOn.code === code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this subscription is a standalone AI add-on subscription
|
||||
*
|
||||
* @return {boolean}
|
||||
*/
|
||||
isStandaloneAiAddOn() {
|
||||
return isStandaloneAiAddOnPlanCode(this.planCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change this subscription's plan
|
||||
*
|
||||
|
@ -262,6 +272,30 @@ class RecurlyAddOn {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A plan configuration
|
||||
*/
|
||||
class RecurlyPlan {
|
||||
/**
|
||||
* @param {object} props
|
||||
* @param {string} props.code
|
||||
* @param {string} props.name
|
||||
*/
|
||||
constructor(props) {
|
||||
this.code = props.code
|
||||
this.name = props.name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given plan code is a standalone AI plan
|
||||
*
|
||||
* @param {string} planCode
|
||||
*/
|
||||
function isStandaloneAiAddOnPlanCode(planCode) {
|
||||
return STANDALONE_AI_ADD_ON_CODES.includes(planCode)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AI_ADD_ON_CODE,
|
||||
RecurlySubscription,
|
||||
|
@ -272,4 +306,6 @@ module.exports = {
|
|||
PaypalPaymentMethod,
|
||||
CreditCardPaymentMethod,
|
||||
RecurlyAddOn,
|
||||
RecurlyPlan,
|
||||
isStandaloneAiAddOnPlanCode,
|
||||
}
|
||||
|
|
|
@ -10,6 +10,10 @@ const Errors = require('../Errors/Errors')
|
|||
const SubscriptionErrors = require('./Errors')
|
||||
const { callbackify } = require('@overleaf/promise-utils')
|
||||
|
||||
/**
|
||||
* @param accountId
|
||||
* @param newEmail
|
||||
*/
|
||||
async function updateAccountEmailAddress(accountId, newEmail) {
|
||||
const data = {
|
||||
email: newEmail,
|
||||
|
@ -814,6 +818,9 @@ const promises = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param xml
|
||||
*/
|
||||
_parseXml(xml) {
|
||||
function convertDataTypes(data) {
|
||||
let key, value
|
||||
|
|
|
@ -31,7 +31,10 @@ const RecurlyClient = require('./RecurlyClient')
|
|||
const { AI_ADD_ON_CODE } = require('./RecurlyEntities')
|
||||
|
||||
/**
|
||||
* @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview'
|
||||
* @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview'
|
||||
* @import { RecurlySubscriptionChange } from './RecurlyEntities'
|
||||
* @import { PaymentMethod } from './types'
|
||||
*/
|
||||
|
||||
const groupPlanModalOptions = Settings.groupPlanModalOptions
|
||||
|
@ -516,38 +519,17 @@ async function previewAddonPurchase(req, res) {
|
|||
)
|
||||
|
||||
/** @type {SubscriptionChangePreview} */
|
||||
const changePreview = {
|
||||
change: {
|
||||
const changePreview = makeChangePreview(
|
||||
{
|
||||
type: 'add-on-purchase',
|
||||
addOn: {
|
||||
code: addOn.code,
|
||||
name: addOn.name,
|
||||
},
|
||||
},
|
||||
currency: subscription.currency,
|
||||
immediateCharge: subscriptionChange.immediateCharge,
|
||||
paymentMethod: paymentMethod.toString(),
|
||||
nextInvoice: {
|
||||
date: subscription.periodEnd.toISOString(),
|
||||
plan: {
|
||||
name: subscriptionChange.nextPlanName,
|
||||
amount: subscriptionChange.nextPlanPrice,
|
||||
},
|
||||
addOns: subscriptionChange.nextAddOns.map(addOn => ({
|
||||
code: addOn.code,
|
||||
name: addOn.name,
|
||||
quantity: addOn.quantity,
|
||||
unitAmount: addOn.unitPrice,
|
||||
amount: addOn.preTaxTotal,
|
||||
})),
|
||||
subtotal: subscriptionChange.subtotal,
|
||||
tax: {
|
||||
rate: subscription.taxRate,
|
||||
amount: subscriptionChange.tax,
|
||||
},
|
||||
total: subscriptionChange.total,
|
||||
},
|
||||
}
|
||||
subscriptionChange,
|
||||
paymentMethod
|
||||
)
|
||||
|
||||
res.render('subscriptions/preview-change', { changePreview })
|
||||
}
|
||||
|
@ -619,6 +601,31 @@ async function removeAddon(req, res, next) {
|
|||
}
|
||||
}
|
||||
|
||||
async function previewSubscription(req, res, next) {
|
||||
const planCode = req.query.planCode
|
||||
if (!planCode) {
|
||||
return HttpErrorHandler.notFound(req, res, 'Missing plan code')
|
||||
}
|
||||
const plan = await RecurlyClient.promises.getPlan(planCode)
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const subscriptionChange =
|
||||
await SubscriptionHandler.promises.previewSubscriptionChange(
|
||||
userId,
|
||||
planCode
|
||||
)
|
||||
const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
|
||||
const changePreview = makeChangePreview(
|
||||
{
|
||||
type: 'premium-subscription',
|
||||
plan: { code: plan.code, name: plan.name },
|
||||
},
|
||||
subscriptionChange,
|
||||
paymentMethod
|
||||
)
|
||||
|
||||
res.render('subscriptions/preview-change', { changePreview })
|
||||
}
|
||||
|
||||
function updateSubscription(req, res, next) {
|
||||
const origin = req && req.query ? req.query.origin : null
|
||||
const user = SessionManager.getSessionUser(req.session)
|
||||
|
@ -887,6 +894,48 @@ async function getLatamCountryBannerDetails(req, res) {
|
|||
return latamCountryBannerDetails
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a subscription change preview for display purposes
|
||||
*
|
||||
* @param {SubscriptionChangeDescription} subscriptionChangeDescription A description of the change for the frontend
|
||||
* @param {RecurlySubscriptionChange} subscriptionChange The subscription change object coming from Recurly
|
||||
* @param {PaymentMethod} paymentMethod The payment method associated to the user
|
||||
* @return {SubscriptionChangePreview}
|
||||
*/
|
||||
function makeChangePreview(
|
||||
subscriptionChangeDescription,
|
||||
subscriptionChange,
|
||||
paymentMethod
|
||||
) {
|
||||
const subscription = subscriptionChange.subscription
|
||||
return {
|
||||
change: subscriptionChangeDescription,
|
||||
currency: subscription.currency,
|
||||
immediateCharge: subscriptionChange.immediateCharge,
|
||||
paymentMethod: paymentMethod.toString(),
|
||||
nextInvoice: {
|
||||
date: subscription.periodEnd.toISOString(),
|
||||
plan: {
|
||||
name: subscriptionChange.nextPlanName,
|
||||
amount: subscriptionChange.nextPlanPrice,
|
||||
},
|
||||
addOns: subscriptionChange.nextAddOns.map(addOn => ({
|
||||
code: addOn.code,
|
||||
name: addOn.name,
|
||||
quantity: addOn.quantity,
|
||||
unitAmount: addOn.unitPrice,
|
||||
amount: addOn.preTaxTotal,
|
||||
})),
|
||||
subtotal: subscriptionChange.subtotal,
|
||||
tax: {
|
||||
rate: subscription.taxRate,
|
||||
amount: subscriptionChange.tax,
|
||||
},
|
||||
total: subscriptionChange.total,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
plansPage: expressify(plansPage),
|
||||
plansPageLightDesign: expressify(plansPageLightDesign),
|
||||
|
@ -896,6 +945,7 @@ module.exports = {
|
|||
cancelSubscription,
|
||||
canceledSubscription: expressify(canceledSubscription),
|
||||
cancelV1Subscription,
|
||||
previewSubscription: expressify(previewSubscription),
|
||||
updateSubscription,
|
||||
cancelPendingSubscriptionChange,
|
||||
updateAccountEmailAddress,
|
||||
|
|
|
@ -65,6 +65,26 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview the effect of changing the subscription plan
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {string} planCode
|
||||
* @return {Promise<RecurlySubscriptionChange>}
|
||||
*/
|
||||
async function previewSubscriptionChange(userId, planCode) {
|
||||
const recurlyId = await getSubscriptionRecurlyId(userId)
|
||||
if (recurlyId == null) {
|
||||
throw new NoRecurlySubscriptionError('Subscription not found', { userId })
|
||||
}
|
||||
|
||||
const subscription = await RecurlyClient.promises.getSubscription(recurlyId)
|
||||
const changeRequest = subscription?.getRequestForPlanChange(planCode)
|
||||
const change =
|
||||
await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
|
||||
return change
|
||||
}
|
||||
|
||||
/**
|
||||
* @param user
|
||||
* @param planCode
|
||||
|
@ -361,6 +381,7 @@ async function getSubscriptionRecurlyId(userId) {
|
|||
module.exports = {
|
||||
validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
|
||||
createSubscription: callbackify(createSubscription),
|
||||
previewSubscriptionChange: callbackify(previewSubscriptionChange),
|
||||
updateSubscription: callbackify(updateSubscription),
|
||||
cancelPendingSubscriptionChange: callbackify(cancelPendingSubscriptionChange),
|
||||
cancelSubscription: callbackify(cancelSubscription),
|
||||
|
@ -374,6 +395,7 @@ module.exports = {
|
|||
promises: {
|
||||
validateNoSubscriptionInRecurly,
|
||||
createSubscription,
|
||||
previewSubscriptionChange,
|
||||
updateSubscription,
|
||||
cancelPendingSubscriptionChange,
|
||||
cancelSubscription,
|
||||
|
|
|
@ -121,6 +121,12 @@ export default {
|
|||
)
|
||||
|
||||
// user changes their account state
|
||||
webRouter.get(
|
||||
'/user/subscription/preview',
|
||||
AuthenticationController.requireLogin(),
|
||||
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
|
||||
SubscriptionController.previewSubscription
|
||||
)
|
||||
webRouter.post(
|
||||
'/user/subscription/update',
|
||||
AuthenticationController.requireLogin(),
|
||||
|
|
|
@ -1485,6 +1485,7 @@
|
|||
"submit_title": "",
|
||||
"subscribe": "",
|
||||
"subscribe_to_find_the_symbols_you_need_faster": "",
|
||||
"subscribe_to_plan": "",
|
||||
"subscription": "",
|
||||
"subscription_admins_cannot_be_deleted": "",
|
||||
"subscription_canceled": "",
|
||||
|
|
|
@ -31,13 +31,17 @@ function PreviewSubscriptionChange() {
|
|||
<Row>
|
||||
<Col md={8} mdOffset={2}>
|
||||
<div className="card p-5">
|
||||
{preview.change.type === 'add-on-purchase' && (
|
||||
{preview.change.type === 'add-on-purchase' ? (
|
||||
<h1>
|
||||
{t('add_add_on_to_your_plan', {
|
||||
addOnName: preview.change.addOn.name,
|
||||
})}
|
||||
</h1>
|
||||
)}
|
||||
) : preview.change.type === 'premium-subscription' ? (
|
||||
<h1>
|
||||
{t('subscribe_to_plan', { planName: preview.change.plan.name })}
|
||||
</h1>
|
||||
) : null}
|
||||
|
||||
{payNowTask.isError && (
|
||||
<Notification
|
||||
|
|
|
@ -2062,6 +2062,7 @@
|
|||
"submit_title": "Submit",
|
||||
"subscribe": "Subscribe",
|
||||
"subscribe_to_find_the_symbols_you_need_faster": "Subscribe to find the symbols you need faster",
|
||||
"subscribe_to_plan": "Subscribe to __planName__",
|
||||
"subscription": "Subscription",
|
||||
"subscription_admin_panel": "admin panel",
|
||||
"subscription_admins_cannot_be_deleted": "You cannot delete your account while on a subscription. Please cancel your subscription and try again. If you keep seeing this message please contact us.",
|
||||
|
|
|
@ -234,7 +234,6 @@
|
|||
"@types/chai": "^4.3.0",
|
||||
"@types/dateformat": "^5.0.2",
|
||||
"@types/diff": "^5.0.9",
|
||||
"uuid": "^9.0.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/events": "^3.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
|
@ -258,8 +257,8 @@
|
|||
"@uppy/react": "^3.2.1",
|
||||
"@uppy/utils": "^5.7.0",
|
||||
"@uppy/xhr-upload": "^3.6.0",
|
||||
"abort-controller": "^3.0.0",
|
||||
"5to6-codemod": "^1.8.0",
|
||||
"abort-controller": "^3.0.0",
|
||||
"acorn": "^7.1.1",
|
||||
"acorn-walk": "^7.1.1",
|
||||
"algoliasearch": "^3.35.1",
|
||||
|
@ -323,6 +322,7 @@
|
|||
"mocha": "^10.2.0",
|
||||
"mocha-each": "^2.0.1",
|
||||
"mock-fs": "^5.1.2",
|
||||
"nock": "^13.5.6",
|
||||
"nvd3": "^1.8.6",
|
||||
"overleaf-editor-core": "*",
|
||||
"pdfjs-dist": "4.6.82",
|
||||
|
@ -363,6 +363,7 @@
|
|||
"to-string-loader": "^1.2.0",
|
||||
"tty-browserify": "^0.0.1",
|
||||
"typescript": "^5.0.4",
|
||||
"uuid": "^9.0.1",
|
||||
"w3c-keyname": "^2.2.8",
|
||||
"webpack": "^5.93.0",
|
||||
"webpack-assets-manifest": "^5.2.1",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
export type SubscriptionChangePreview = {
|
||||
change: SubscriptionChange
|
||||
change: SubscriptionChangeDescription
|
||||
currency: string
|
||||
paymentMethod: string
|
||||
immediateCharge: number
|
||||
|
@ -27,7 +27,7 @@ type AddOn = {
|
|||
amount: number
|
||||
}
|
||||
|
||||
type SubscriptionChange = AddOnPurchase
|
||||
export type SubscriptionChangeDescription = AddOnPurchase | PremiumSubscription
|
||||
|
||||
type AddOnPurchase = {
|
||||
type: 'add-on-purchase'
|
||||
|
@ -36,3 +36,11 @@ type AddOnPurchase = {
|
|||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
type PremiumSubscription = {
|
||||
type: 'premium-subscription'
|
||||
plan: {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue