mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #21959 from overleaf/em-redeploy-ai-add-on-prs
Redeploy AI add-on PRs GitOrigin-RevId: d234ac0862947e9ea8926055ad205e32a456dd2e
This commit is contained in:
parent
58e34617bc
commit
6e39885fde
18 changed files with 507 additions and 89 deletions
27
package-lock.json
generated
27
package-lock.json
generated
|
@ -41634,6 +41634,7 @@
|
||||||
"mocha": "^10.2.0",
|
"mocha": "^10.2.0",
|
||||||
"mocha-each": "^2.0.1",
|
"mocha-each": "^2.0.1",
|
||||||
"mock-fs": "^5.1.2",
|
"mock-fs": "^5.1.2",
|
||||||
|
"nock": "^13.5.6",
|
||||||
"nvd3": "^1.8.6",
|
"nvd3": "^1.8.6",
|
||||||
"overleaf-editor-core": "*",
|
"overleaf-editor-core": "*",
|
||||||
"pdfjs-dist": "4.6.82",
|
"pdfjs-dist": "4.6.82",
|
||||||
|
@ -43759,6 +43760,20 @@
|
||||||
"@sinonjs/commons": "^1.7.0"
|
"@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": {
|
"services/web/node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
@ -51197,6 +51212,7 @@
|
||||||
"mongoose": "8.5.3",
|
"mongoose": "8.5.3",
|
||||||
"multer": "overleaf/multer#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e",
|
"multer": "overleaf/multer#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e",
|
||||||
"nocache": "^2.1.0",
|
"nocache": "^2.1.0",
|
||||||
|
"nock": "^13.5.6",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^6.7.0",
|
"nodemailer": "^6.7.0",
|
||||||
"nodemailer-ses-transport": "^1.5.1",
|
"nodemailer-ses-transport": "^1.5.1",
|
||||||
|
@ -52767,6 +52783,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": {
|
"on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
|
|
@ -13,6 +13,7 @@ const {
|
||||||
PaypalPaymentMethod,
|
PaypalPaymentMethod,
|
||||||
CreditCardPaymentMethod,
|
CreditCardPaymentMethod,
|
||||||
RecurlyAddOn,
|
RecurlyAddOn,
|
||||||
|
RecurlyPlan,
|
||||||
} = require('./RecurlyEntities')
|
} = require('./RecurlyEntities')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -67,6 +68,45 @@ async function getSubscription(subscriptionId) {
|
||||||
return subscriptionFromApi(subscription)
|
return subscriptionFromApi(subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the subscription for a given user
|
||||||
|
*
|
||||||
|
* Returns null if the user doesn't have an account or a subscription. Throws an
|
||||||
|
* error if the user has more than one subscription.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @return {Promise<RecurlySubscription | null>}
|
||||||
|
*/
|
||||||
|
async function getSubscriptionForUser(userId) {
|
||||||
|
try {
|
||||||
|
const subscriptions = client.listAccountSubscriptions(`code-${userId}`, {
|
||||||
|
params: { state: 'active', limit: 2 },
|
||||||
|
})
|
||||||
|
|
||||||
|
let result = null
|
||||||
|
|
||||||
|
// The async iterator returns a NotFoundError if the account doesn't exist.
|
||||||
|
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)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof recurly.errors.NotFoundError) {
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request a susbcription change from Recurly
|
* Request a susbcription change from Recurly
|
||||||
*
|
*
|
||||||
|
@ -161,6 +201,17 @@ async function getAddOn(planCode, addOnCode) {
|
||||||
return addOnFromApi(addOn)
|
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) {
|
function subscriptionIsCanceledOrExpired(subscription) {
|
||||||
const state = subscription?.recurlyStatus?.state
|
const state = subscription?.recurlyStatus?.state
|
||||||
return state === 'canceled' || state === 'expired'
|
return state === 'canceled' || state === 'expired'
|
||||||
|
@ -169,41 +220,53 @@ function subscriptionIsCanceledOrExpired(subscription) {
|
||||||
/**
|
/**
|
||||||
* Build a RecurlySubscription from Recurly API data
|
* Build a RecurlySubscription from Recurly API data
|
||||||
*
|
*
|
||||||
* @param {recurly.Subscription} subscription
|
* @param {recurly.Subscription} apiSubscription
|
||||||
* @return {RecurlySubscription}
|
* @return {RecurlySubscription}
|
||||||
*/
|
*/
|
||||||
function subscriptionFromApi(subscription) {
|
function subscriptionFromApi(apiSubscription) {
|
||||||
if (
|
if (
|
||||||
subscription.uuid == null ||
|
apiSubscription.uuid == null ||
|
||||||
subscription.plan == null ||
|
apiSubscription.plan == null ||
|
||||||
subscription.plan.code == null ||
|
apiSubscription.plan.code == null ||
|
||||||
subscription.plan.name == null ||
|
apiSubscription.plan.name == null ||
|
||||||
subscription.account == null ||
|
apiSubscription.account == null ||
|
||||||
subscription.account.code == null ||
|
apiSubscription.account.code == null ||
|
||||||
subscription.unitAmount == null ||
|
apiSubscription.unitAmount == null ||
|
||||||
subscription.subtotal == null ||
|
apiSubscription.subtotal == null ||
|
||||||
subscription.total == null ||
|
apiSubscription.total == null ||
|
||||||
subscription.currency == null ||
|
apiSubscription.currency == null ||
|
||||||
subscription.currentPeriodStartedAt == null ||
|
apiSubscription.currentPeriodStartedAt == null ||
|
||||||
subscription.currentPeriodEndsAt == null
|
apiSubscription.currentPeriodEndsAt == null
|
||||||
) {
|
) {
|
||||||
throw new OError('Invalid Recurly subscription', { subscription })
|
throw new OError('Invalid Recurly subscription', {
|
||||||
|
subscription: apiSubscription,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return new RecurlySubscription({
|
|
||||||
id: subscription.uuid,
|
const subscription = new RecurlySubscription({
|
||||||
userId: subscription.account.code,
|
id: apiSubscription.uuid,
|
||||||
planCode: subscription.plan.code,
|
userId: apiSubscription.account.code,
|
||||||
planName: subscription.plan.name,
|
planCode: apiSubscription.plan.code,
|
||||||
planPrice: subscription.unitAmount,
|
planName: apiSubscription.plan.name,
|
||||||
addOns: (subscription.addOns ?? []).map(subscriptionAddOnFromApi),
|
planPrice: apiSubscription.unitAmount,
|
||||||
subtotal: subscription.subtotal,
|
addOns: (apiSubscription.addOns ?? []).map(subscriptionAddOnFromApi),
|
||||||
taxRate: subscription.taxInfo?.rate ?? 0,
|
subtotal: apiSubscription.subtotal,
|
||||||
taxAmount: subscription.tax ?? 0,
|
taxRate: apiSubscription.taxInfo?.rate ?? 0,
|
||||||
total: subscription.total,
|
taxAmount: apiSubscription.tax ?? 0,
|
||||||
currency: subscription.currency,
|
total: apiSubscription.total,
|
||||||
periodStart: subscription.currentPeriodStartedAt,
|
currency: apiSubscription.currency,
|
||||||
periodEnd: subscription.currentPeriodEndsAt,
|
periodStart: apiSubscription.currentPeriodStartedAt,
|
||||||
|
periodEnd: apiSubscription.currentPeriodEndsAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (apiSubscription.pendingChange != null) {
|
||||||
|
subscription.pendingChange = subscriptionChangeFromApi(
|
||||||
|
subscription,
|
||||||
|
apiSubscription.pendingChange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -251,14 +314,22 @@ function subscriptionChangeFromApi(subscription, subscriptionChange) {
|
||||||
const nextAddOns = (subscriptionChange.addOns ?? []).map(
|
const nextAddOns = (subscriptionChange.addOns ?? []).map(
|
||||||
subscriptionAddOnFromApi
|
subscriptionAddOnFromApi
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let immediateCharge =
|
||||||
|
subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0
|
||||||
|
for (const creditInvoice of subscriptionChange.invoiceCollection
|
||||||
|
?.creditInvoices ?? []) {
|
||||||
|
// The credit invoice totals are already negative
|
||||||
|
immediateCharge += creditInvoice.total ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
return new RecurlySubscriptionChange({
|
return new RecurlySubscriptionChange({
|
||||||
subscription,
|
subscription,
|
||||||
nextPlanCode: subscriptionChange.plan.code,
|
nextPlanCode: subscriptionChange.plan.code,
|
||||||
nextPlanName: subscriptionChange.plan.name,
|
nextPlanName: subscriptionChange.plan.name,
|
||||||
nextPlanPrice: subscriptionChange.unitAmount,
|
nextPlanPrice: subscriptionChange.unitAmount,
|
||||||
nextAddOns,
|
nextAddOns,
|
||||||
immediateCharge:
|
immediateCharge,
|
||||||
subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -303,6 +374,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
|
* Build an API request from a RecurlySubscriptionChangeRequest
|
||||||
*
|
*
|
||||||
|
@ -339,6 +426,7 @@ module.exports = {
|
||||||
getAccountForUserId: callbackify(getAccountForUserId),
|
getAccountForUserId: callbackify(getAccountForUserId),
|
||||||
createAccountForUserId: callbackify(createAccountForUserId),
|
createAccountForUserId: callbackify(createAccountForUserId),
|
||||||
getSubscription: callbackify(getSubscription),
|
getSubscription: callbackify(getSubscription),
|
||||||
|
getSubscriptionForUser: callbackify(getSubscriptionForUser),
|
||||||
previewSubscriptionChange: callbackify(previewSubscriptionChange),
|
previewSubscriptionChange: callbackify(previewSubscriptionChange),
|
||||||
applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
|
applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
|
||||||
removeSubscriptionChange: callbackify(removeSubscriptionChange),
|
removeSubscriptionChange: callbackify(removeSubscriptionChange),
|
||||||
|
@ -347,10 +435,12 @@ module.exports = {
|
||||||
cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
|
cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
|
||||||
getPaymentMethod: callbackify(getPaymentMethod),
|
getPaymentMethod: callbackify(getPaymentMethod),
|
||||||
getAddOn: callbackify(getAddOn),
|
getAddOn: callbackify(getAddOn),
|
||||||
|
getPlan: callbackify(getPlan),
|
||||||
subscriptionIsCanceledOrExpired,
|
subscriptionIsCanceledOrExpired,
|
||||||
|
|
||||||
promises: {
|
promises: {
|
||||||
getSubscription,
|
getSubscription,
|
||||||
|
getSubscriptionForUser,
|
||||||
getAccountForUserId,
|
getAccountForUserId,
|
||||||
createAccountForUserId,
|
createAccountForUserId,
|
||||||
previewSubscriptionChange,
|
previewSubscriptionChange,
|
||||||
|
@ -361,5 +451,6 @@ module.exports = {
|
||||||
cancelSubscriptionByUuid,
|
cancelSubscriptionByUuid,
|
||||||
getPaymentMethod,
|
getPaymentMethod,
|
||||||
getAddOn,
|
getAddOn,
|
||||||
|
getPlan,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ const PlansLocator = require('./PlansLocator')
|
||||||
const SubscriptionHelper = require('./SubscriptionHelper')
|
const SubscriptionHelper = require('./SubscriptionHelper')
|
||||||
|
|
||||||
const AI_ADD_ON_CODE = 'assistant'
|
const AI_ADD_ON_CODE = 'assistant'
|
||||||
|
const STANDALONE_AI_ADD_ON_CODES = ['assistant', 'assistant-annual']
|
||||||
|
|
||||||
class RecurlySubscription {
|
class RecurlySubscription {
|
||||||
/**
|
/**
|
||||||
|
@ -23,6 +24,7 @@ class RecurlySubscription {
|
||||||
* @param {number} props.total
|
* @param {number} props.total
|
||||||
* @param {Date} props.periodStart
|
* @param {Date} props.periodStart
|
||||||
* @param {Date} props.periodEnd
|
* @param {Date} props.periodEnd
|
||||||
|
* @param {RecurlySubscriptionChange} [props.pendingChange]
|
||||||
*/
|
*/
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
this.id = props.id
|
this.id = props.id
|
||||||
|
@ -38,12 +40,47 @@ class RecurlySubscription {
|
||||||
this.total = props.total
|
this.total = props.total
|
||||||
this.periodStart = props.periodStart
|
this.periodStart = props.periodStart
|
||||||
this.periodEnd = props.periodEnd
|
this.periodEnd = props.periodEnd
|
||||||
|
this.pendingChange = props.pendingChange ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether this subscription currently has the given add-on
|
||||||
|
*
|
||||||
|
* @param {string} code
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
hasAddOn(code) {
|
hasAddOn(code) {
|
||||||
return this.addOns.some(addOn => addOn.code === code)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether this subcription will have the given add-on next billing
|
||||||
|
* period.
|
||||||
|
*
|
||||||
|
* There are two cases: either the subscription already has the add-on and
|
||||||
|
* won't change next period, or the subscription will change next period and
|
||||||
|
* the change includes the add-on.
|
||||||
|
*
|
||||||
|
* @param {string} code
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
hasAddOnNextPeriod(code) {
|
||||||
|
if (this.pendingChange != null) {
|
||||||
|
return this.pendingChange.nextAddOns.some(addOn => addOn.code === code)
|
||||||
|
} else {
|
||||||
|
return this.hasAddOn(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change this subscription's plan
|
* Change this subscription's plan
|
||||||
*
|
*
|
||||||
|
@ -60,16 +97,31 @@ class RecurlySubscription {
|
||||||
if (newPlan == null) {
|
if (newPlan == null) {
|
||||||
throw new OError('Unable to find plan in settings', { planCode })
|
throw new OError('Unable to find plan in settings', { planCode })
|
||||||
}
|
}
|
||||||
const changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
|
const shouldChangeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
|
||||||
currentPlan,
|
currentPlan,
|
||||||
newPlan
|
newPlan
|
||||||
)
|
)
|
||||||
const timeframe = changeAtTermEnd ? 'term_end' : 'now'
|
|
||||||
return new RecurlySubscriptionChangeRequest({
|
const changeRequest = new RecurlySubscriptionChangeRequest({
|
||||||
subscription: this,
|
subscription: this,
|
||||||
timeframe,
|
timeframe: shouldChangeAtTermEnd ? 'term_end' : 'now',
|
||||||
planCode,
|
planCode,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Carry the AI add-on to the new plan if applicable
|
||||||
|
if (
|
||||||
|
this.isStandaloneAiAddOn() ||
|
||||||
|
(!shouldChangeAtTermEnd && this.hasAddOn(AI_ADD_ON_CODE)) ||
|
||||||
|
(shouldChangeAtTermEnd && this.hasAddOnNextPeriod(AI_ADD_ON_CODE))
|
||||||
|
) {
|
||||||
|
const addOnUpdate = new RecurlySubscriptionAddOnUpdate({
|
||||||
|
code: AI_ADD_ON_CODE,
|
||||||
|
quantity: 1,
|
||||||
|
})
|
||||||
|
changeRequest.addOnUpdates = [addOnUpdate]
|
||||||
|
}
|
||||||
|
|
||||||
|
return changeRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -262,6 +314,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 = {
|
module.exports = {
|
||||||
AI_ADD_ON_CODE,
|
AI_ADD_ON_CODE,
|
||||||
RecurlySubscription,
|
RecurlySubscription,
|
||||||
|
@ -272,4 +348,6 @@ module.exports = {
|
||||||
PaypalPaymentMethod,
|
PaypalPaymentMethod,
|
||||||
CreditCardPaymentMethod,
|
CreditCardPaymentMethod,
|
||||||
RecurlyAddOn,
|
RecurlyAddOn,
|
||||||
|
RecurlyPlan,
|
||||||
|
isStandaloneAiAddOnPlanCode,
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,10 @@ const Errors = require('../Errors/Errors')
|
||||||
const SubscriptionErrors = require('./Errors')
|
const SubscriptionErrors = require('./Errors')
|
||||||
const { callbackify } = require('@overleaf/promise-utils')
|
const { callbackify } = require('@overleaf/promise-utils')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param accountId
|
||||||
|
* @param newEmail
|
||||||
|
*/
|
||||||
async function updateAccountEmailAddress(accountId, newEmail) {
|
async function updateAccountEmailAddress(accountId, newEmail) {
|
||||||
const data = {
|
const data = {
|
||||||
email: newEmail,
|
email: newEmail,
|
||||||
|
@ -814,6 +818,9 @@ const promises = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param xml
|
||||||
|
*/
|
||||||
_parseXml(xml) {
|
_parseXml(xml) {
|
||||||
function convertDataTypes(data) {
|
function convertDataTypes(data) {
|
||||||
let key, value
|
let key, value
|
||||||
|
|
|
@ -29,9 +29,13 @@ const HttpErrorHandler = require('../Errors/HttpErrorHandler')
|
||||||
const { URLSearchParams } = require('url')
|
const { URLSearchParams } = require('url')
|
||||||
const RecurlyClient = require('./RecurlyClient')
|
const RecurlyClient = require('./RecurlyClient')
|
||||||
const { AI_ADD_ON_CODE } = require('./RecurlyEntities')
|
const { AI_ADD_ON_CODE } = require('./RecurlyEntities')
|
||||||
|
const PlansLocator = require('./PlansLocator')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview'
|
||||||
* @import { SubscriptionChangePreview } 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
|
const groupPlanModalOptions = Settings.groupPlanModalOptions
|
||||||
|
@ -516,38 +520,17 @@ async function previewAddonPurchase(req, res) {
|
||||||
)
|
)
|
||||||
|
|
||||||
/** @type {SubscriptionChangePreview} */
|
/** @type {SubscriptionChangePreview} */
|
||||||
const changePreview = {
|
const changePreview = makeChangePreview(
|
||||||
change: {
|
{
|
||||||
type: 'add-on-purchase',
|
type: 'add-on-purchase',
|
||||||
addOn: {
|
addOn: {
|
||||||
code: addOn.code,
|
code: addOn.code,
|
||||||
name: addOn.name,
|
name: addOn.name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
currency: subscription.currency,
|
subscriptionChange,
|
||||||
immediateCharge: subscriptionChange.immediateCharge,
|
paymentMethod
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
res.render('subscriptions/preview-change', { changePreview })
|
res.render('subscriptions/preview-change', { changePreview })
|
||||||
}
|
}
|
||||||
|
@ -619,6 +602,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) {
|
function updateSubscription(req, res, next) {
|
||||||
const origin = req && req.query ? req.query.origin : null
|
const origin = req && req.query ? req.query.origin : null
|
||||||
const user = SessionManager.getSessionUser(req.session)
|
const user = SessionManager.getSessionUser(req.session)
|
||||||
|
@ -887,6 +895,54 @@ async function getLatamCountryBannerDetails(req, res) {
|
||||||
return latamCountryBannerDetails
|
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
|
||||||
|
const nextPlan = PlansLocator.findLocalPlanInSettings(
|
||||||
|
subscriptionChange.nextPlanCode
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
change: subscriptionChangeDescription,
|
||||||
|
currency: subscription.currency,
|
||||||
|
immediateCharge: subscriptionChange.immediateCharge,
|
||||||
|
paymentMethod: paymentMethod.toString(),
|
||||||
|
nextPlan: {
|
||||||
|
annual: nextPlan.annual ?? false,
|
||||||
|
},
|
||||||
|
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 = {
|
module.exports = {
|
||||||
plansPage: expressify(plansPage),
|
plansPage: expressify(plansPage),
|
||||||
plansPageLightDesign: expressify(plansPageLightDesign),
|
plansPageLightDesign: expressify(plansPageLightDesign),
|
||||||
|
@ -896,6 +952,7 @@ module.exports = {
|
||||||
cancelSubscription,
|
cancelSubscription,
|
||||||
canceledSubscription: expressify(canceledSubscription),
|
canceledSubscription: expressify(canceledSubscription),
|
||||||
cancelV1Subscription,
|
cancelV1Subscription,
|
||||||
|
previewSubscription: expressify(previewSubscription),
|
||||||
updateSubscription,
|
updateSubscription,
|
||||||
cancelPendingSubscriptionChange,
|
cancelPendingSubscriptionChange,
|
||||||
updateAccountEmailAddress,
|
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 user
|
||||||
* @param planCode
|
* @param planCode
|
||||||
|
@ -361,6 +381,7 @@ async function getSubscriptionRecurlyId(userId) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
|
validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
|
||||||
createSubscription: callbackify(createSubscription),
|
createSubscription: callbackify(createSubscription),
|
||||||
|
previewSubscriptionChange: callbackify(previewSubscriptionChange),
|
||||||
updateSubscription: callbackify(updateSubscription),
|
updateSubscription: callbackify(updateSubscription),
|
||||||
cancelPendingSubscriptionChange: callbackify(cancelPendingSubscriptionChange),
|
cancelPendingSubscriptionChange: callbackify(cancelPendingSubscriptionChange),
|
||||||
cancelSubscription: callbackify(cancelSubscription),
|
cancelSubscription: callbackify(cancelSubscription),
|
||||||
|
@ -374,6 +395,7 @@ module.exports = {
|
||||||
promises: {
|
promises: {
|
||||||
validateNoSubscriptionInRecurly,
|
validateNoSubscriptionInRecurly,
|
||||||
createSubscription,
|
createSubscription,
|
||||||
|
previewSubscriptionChange,
|
||||||
updateSubscription,
|
updateSubscription,
|
||||||
cancelPendingSubscriptionChange,
|
cancelPendingSubscriptionChange,
|
||||||
cancelSubscription,
|
cancelSubscription,
|
||||||
|
|
|
@ -121,6 +121,12 @@ export default {
|
||||||
)
|
)
|
||||||
|
|
||||||
// user changes their account state
|
// user changes their account state
|
||||||
|
webRouter.get(
|
||||||
|
'/user/subscription/preview',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
|
||||||
|
SubscriptionController.previewSubscription
|
||||||
|
)
|
||||||
webRouter.post(
|
webRouter.post(
|
||||||
'/user/subscription/update',
|
'/user/subscription/update',
|
||||||
AuthenticationController.requireLogin(),
|
AuthenticationController.requireLogin(),
|
||||||
|
|
|
@ -67,6 +67,7 @@
|
||||||
"add_company_details_lowercase": "",
|
"add_company_details_lowercase": "",
|
||||||
"add_email_address": "",
|
"add_email_address": "",
|
||||||
"add_email_to_claim_features": "",
|
"add_email_to_claim_features": "",
|
||||||
|
"add_error_assist_to_your_projects": "",
|
||||||
"add_files": "",
|
"add_files": "",
|
||||||
"add_more_collaborators": "",
|
"add_more_collaborators": "",
|
||||||
"add_more_editors": "",
|
"add_more_editors": "",
|
||||||
|
@ -1482,6 +1483,7 @@
|
||||||
"submit_title": "",
|
"submit_title": "",
|
||||||
"subscribe": "",
|
"subscribe": "",
|
||||||
"subscribe_to_find_the_symbols_you_need_faster": "",
|
"subscribe_to_find_the_symbols_you_need_faster": "",
|
||||||
|
"subscribe_to_plan": "",
|
||||||
"subscription": "",
|
"subscription": "",
|
||||||
"subscription_admins_cannot_be_deleted": "",
|
"subscription_admins_cannot_be_deleted": "",
|
||||||
"subscription_canceled": "",
|
"subscription_canceled": "",
|
||||||
|
|
|
@ -7,11 +7,7 @@ import { ExpiredSubscription } from './states/expired'
|
||||||
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
|
import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
|
||||||
import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email'
|
import PersonalSubscriptionRecurlySyncEmail from './personal-subscription-recurly-sync-email'
|
||||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||||
import {
|
import { isStandaloneAiPlanCode, AI_ADD_ON_CODE } from '../../data/add-on-codes'
|
||||||
AI_STANDALONE_PLAN_CODE,
|
|
||||||
AI_STANDALONE_ANNUAL_PLAN_CODE,
|
|
||||||
AI_ADD_ON_CODE,
|
|
||||||
} from '../../data/add-on-codes'
|
|
||||||
|
|
||||||
function PastDueSubscriptionAlert({
|
function PastDueSubscriptionAlert({
|
||||||
subscription,
|
subscription,
|
||||||
|
@ -50,11 +46,7 @@ function PersonalSubscriptionStates({
|
||||||
addOn => addOn.addOnCode === AI_ADD_ON_CODE
|
addOn => addOn.addOnCode === AI_ADD_ON_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAiStandalonePlan = [
|
const onAiStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
|
||||||
AI_STANDALONE_PLAN_CODE,
|
|
||||||
AI_STANDALONE_ANNUAL_PLAN_CODE,
|
|
||||||
].includes(subscription.planCode)
|
|
||||||
|
|
||||||
const planHasAi = onAiStandalonePlan || hasAiAddon
|
const planHasAi = onAiStandalonePlan || hasAiAddon
|
||||||
|
|
||||||
if (state === 'active' && planHasAi) {
|
if (state === 'active' && planHasAi) {
|
||||||
|
|
|
@ -15,8 +15,7 @@ import {
|
||||||
ADD_ON_NAME,
|
ADD_ON_NAME,
|
||||||
AI_ADD_ON_CODE,
|
AI_ADD_ON_CODE,
|
||||||
AI_STANDALONE_PLAN_NAME,
|
AI_STANDALONE_PLAN_NAME,
|
||||||
AI_STANDALONE_PLAN_CODE,
|
isStandaloneAiPlanCode,
|
||||||
AI_STANDALONE_ANNUAL_PLAN_CODE,
|
|
||||||
} from '../../../../data/add-on-codes'
|
} from '../../../../data/add-on-codes'
|
||||||
import { CancelSubscriptionButton } from './cancel-subscription-button'
|
import { CancelSubscriptionButton } from './cancel-subscription-button'
|
||||||
|
|
||||||
|
@ -32,10 +31,7 @@ export function ActiveAiAddonSubscription({
|
||||||
useSubscriptionDashboardContext()
|
useSubscriptionDashboardContext()
|
||||||
if (showCancellation) return <CancelSubscription />
|
if (showCancellation) return <CancelSubscription />
|
||||||
|
|
||||||
const onStandalonePlan = [
|
const onStandalonePlan = isStandaloneAiPlanCode(subscription.planCode)
|
||||||
AI_STANDALONE_PLAN_CODE,
|
|
||||||
AI_STANDALONE_ANNUAL_PLAN_CODE,
|
|
||||||
].includes(subscription.planCode)
|
|
||||||
|
|
||||||
const handlePlanChange = () => setModalIdShown('change-plan')
|
const handlePlanChange = () => setModalIdShown('change-plan')
|
||||||
|
|
||||||
|
|
|
@ -14,8 +14,8 @@ import OLButton from '@/features/ui/components/ol/ol-button'
|
||||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||||
import {
|
import {
|
||||||
AI_ADD_ON_CODE,
|
AI_ADD_ON_CODE,
|
||||||
AI_STANDALONE_PLAN_CODE,
|
|
||||||
ADD_ON_NAME,
|
ADD_ON_NAME,
|
||||||
|
isStandaloneAiPlanCode,
|
||||||
} from '../../../../../../data/add-on-codes'
|
} from '../../../../../../data/add-on-codes'
|
||||||
import {
|
import {
|
||||||
cancelSubscriptionUrl,
|
cancelSubscriptionUrl,
|
||||||
|
@ -33,7 +33,7 @@ export function CancelAiAddOnModal() {
|
||||||
|
|
||||||
if (!personalSubscription) return null
|
if (!personalSubscription) return null
|
||||||
|
|
||||||
const onStandalone = personalSubscription.planCode === AI_STANDALONE_PLAN_CODE
|
const onStandalone = isStandaloneAiPlanCode(personalSubscription.planCode)
|
||||||
|
|
||||||
const cancellationEndpoint = onStandalone
|
const cancellationEndpoint = onStandalone
|
||||||
? cancelSubscriptionUrl
|
? cancelSubscriptionUrl
|
||||||
|
|
|
@ -31,13 +31,17 @@ function PreviewSubscriptionChange() {
|
||||||
<Row>
|
<Row>
|
||||||
<Col md={8} mdOffset={2}>
|
<Col md={8} mdOffset={2}>
|
||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
{preview.change.type === 'add-on-purchase' && (
|
{preview.change.type === 'add-on-purchase' ? (
|
||||||
<h1>
|
<h1>
|
||||||
{t('add_add_on_to_your_plan', {
|
{t('add_add_on_to_your_plan', {
|
||||||
addOnName: preview.change.addOn.name,
|
addOnName: preview.change.addOn.name,
|
||||||
})}
|
})}
|
||||||
</h1>
|
</h1>
|
||||||
)}
|
) : preview.change.type === 'premium-subscription' ? (
|
||||||
|
<h1>
|
||||||
|
{t('subscribe_to_plan', { planName: preview.change.plan.name })}
|
||||||
|
</h1>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{payNowTask.isError && (
|
{payNowTask.isError && (
|
||||||
<Notification
|
<Notification
|
||||||
|
@ -111,7 +115,11 @@ function PreviewSubscriptionChange() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Row className="mt-1">
|
<Row className="mt-1">
|
||||||
<Col xs={9}>{t('total_per_month')}</Col>
|
<Col xs={9}>
|
||||||
|
{preview.nextPlan.annual
|
||||||
|
? t('total_per_year')
|
||||||
|
: t('total_per_month')}
|
||||||
|
</Col>
|
||||||
<Col xs={3} className="text-right">
|
<Col xs={3} className="text-right">
|
||||||
{formatCurrencyLocalized(
|
{formatCurrencyLocalized(
|
||||||
preview.nextInvoice.total,
|
preview.nextInvoice.total,
|
||||||
|
|
|
@ -3,4 +3,8 @@ export const AI_ADD_ON_CODE = 'assistant'
|
||||||
// we dont want translations on plan or add-on names
|
// we dont want translations on plan or add-on names
|
||||||
export const ADD_ON_NAME = "Error Assist"
|
export const ADD_ON_NAME = "Error Assist"
|
||||||
export const AI_STANDALONE_PLAN_NAME = "Overleaf Free"
|
export const AI_STANDALONE_PLAN_NAME = "Overleaf Free"
|
||||||
export const AI_STANDALONE_ANNUAL_PLAN_CODE = 'assistant-annual'
|
export const AI_STANDALONE_ANNUAL_PLAN_CODE = 'assistant-annual'
|
||||||
|
|
||||||
|
export function isStandaloneAiPlanCode(planCode: string) {
|
||||||
|
return planCode === AI_STANDALONE_PLAN_CODE || planCode === AI_STANDALONE_ANNUAL_PLAN_CODE
|
||||||
|
}
|
||||||
|
|
|
@ -79,6 +79,7 @@
|
||||||
"add_email": "Add Email",
|
"add_email": "Add Email",
|
||||||
"add_email_address": "Add email address",
|
"add_email_address": "Add email address",
|
||||||
"add_email_to_claim_features": "Add an institutional email address to claim your features.",
|
"add_email_to_claim_features": "Add an institutional email address to claim your features.",
|
||||||
|
"add_error_assist_to_your_projects": "Add Error Assist to your projects and get AI help to fix LaTeX errors faster.",
|
||||||
"add_files": "Add Files",
|
"add_files": "Add Files",
|
||||||
"add_more_collaborators": "Add more collaborators",
|
"add_more_collaborators": "Add more collaborators",
|
||||||
"add_more_editors": "Add more editors",
|
"add_more_editors": "Add more editors",
|
||||||
|
@ -2059,6 +2060,7 @@
|
||||||
"submit_title": "Submit",
|
"submit_title": "Submit",
|
||||||
"subscribe": "Subscribe",
|
"subscribe": "Subscribe",
|
||||||
"subscribe_to_find_the_symbols_you_need_faster": "Subscribe to find the symbols you need faster",
|
"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": "Subscription",
|
||||||
"subscription_admin_panel": "admin panel",
|
"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.",
|
"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/chai": "^4.3.0",
|
||||||
"@types/dateformat": "^5.0.2",
|
"@types/dateformat": "^5.0.2",
|
||||||
"@types/diff": "^5.0.9",
|
"@types/diff": "^5.0.9",
|
||||||
"uuid": "^9.0.1",
|
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/events": "^3.0.0",
|
"@types/events": "^3.0.0",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.13",
|
||||||
|
@ -258,8 +257,8 @@
|
||||||
"@uppy/react": "^3.2.1",
|
"@uppy/react": "^3.2.1",
|
||||||
"@uppy/utils": "^5.7.0",
|
"@uppy/utils": "^5.7.0",
|
||||||
"@uppy/xhr-upload": "^3.6.0",
|
"@uppy/xhr-upload": "^3.6.0",
|
||||||
"abort-controller": "^3.0.0",
|
|
||||||
"5to6-codemod": "^1.8.0",
|
"5to6-codemod": "^1.8.0",
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
"acorn": "^7.1.1",
|
"acorn": "^7.1.1",
|
||||||
"acorn-walk": "^7.1.1",
|
"acorn-walk": "^7.1.1",
|
||||||
"algoliasearch": "^3.35.1",
|
"algoliasearch": "^3.35.1",
|
||||||
|
@ -323,6 +322,7 @@
|
||||||
"mocha": "^10.2.0",
|
"mocha": "^10.2.0",
|
||||||
"mocha-each": "^2.0.1",
|
"mocha-each": "^2.0.1",
|
||||||
"mock-fs": "^5.1.2",
|
"mock-fs": "^5.1.2",
|
||||||
|
"nock": "^13.5.6",
|
||||||
"nvd3": "^1.8.6",
|
"nvd3": "^1.8.6",
|
||||||
"overleaf-editor-core": "*",
|
"overleaf-editor-core": "*",
|
||||||
"pdfjs-dist": "4.6.82",
|
"pdfjs-dist": "4.6.82",
|
||||||
|
@ -363,6 +363,7 @@
|
||||||
"to-string-loader": "^1.2.0",
|
"to-string-loader": "^1.2.0",
|
||||||
"tty-browserify": "^0.0.1",
|
"tty-browserify": "^0.0.1",
|
||||||
"typescript": "^5.0.4",
|
"typescript": "^5.0.4",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
"w3c-keyname": "^2.2.8",
|
"w3c-keyname": "^2.2.8",
|
||||||
"webpack": "^5.93.0",
|
"webpack": "^5.93.0",
|
||||||
"webpack-assets-manifest": "^5.2.1",
|
"webpack-assets-manifest": "^5.2.1",
|
||||||
|
|
|
@ -3,6 +3,7 @@ const { expect } = require('chai')
|
||||||
const recurly = require('recurly')
|
const recurly = require('recurly')
|
||||||
const SandboxedModule = require('sandboxed-module')
|
const SandboxedModule = require('sandboxed-module')
|
||||||
const {
|
const {
|
||||||
|
RecurlySubscription,
|
||||||
RecurlySubscriptionChangeRequest,
|
RecurlySubscriptionChangeRequest,
|
||||||
RecurlySubscriptionAddOnUpdate,
|
RecurlySubscriptionAddOnUpdate,
|
||||||
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
|
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
|
||||||
|
@ -35,7 +36,7 @@ describe('RecurlyClient', function () {
|
||||||
preTaxTotal: 2,
|
preTaxTotal: 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.subscription = {
|
this.subscription = new RecurlySubscription({
|
||||||
id: 'subscription-id',
|
id: 'subscription-id',
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
|
@ -49,7 +50,7 @@ describe('RecurlyClient', function () {
|
||||||
total: 16.5,
|
total: 16.5,
|
||||||
periodStart: new Date(),
|
periodStart: new Date(),
|
||||||
periodEnd: new Date(),
|
periodEnd: new Date(),
|
||||||
}
|
})
|
||||||
|
|
||||||
this.recurlySubscription = {
|
this.recurlySubscription = {
|
||||||
uuid: this.subscription.id,
|
uuid: this.subscription.id,
|
||||||
|
@ -96,6 +97,7 @@ describe('RecurlyClient', function () {
|
||||||
let client
|
let client
|
||||||
this.client = client = {
|
this.client = client = {
|
||||||
getAccount: sinon.stub(),
|
getAccount: sinon.stub(),
|
||||||
|
listAccountSubscriptions: sinon.stub(),
|
||||||
}
|
}
|
||||||
this.recurly = {
|
this.recurly = {
|
||||||
errors: recurly.errors,
|
errors: recurly.errors,
|
||||||
|
@ -198,6 +200,54 @@ describe('RecurlyClient', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getSubscriptionForUser', function () {
|
||||||
|
it("should return null if the account doesn't exist", async function () {
|
||||||
|
this.client.listAccountSubscriptions.returns({
|
||||||
|
// eslint-disable-next-line require-yield
|
||||||
|
each: async function* () {
|
||||||
|
throw new recurly.errors.NotFoundError('account not found')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const subscription =
|
||||||
|
await this.RecurlyClient.promises.getSubscriptionForUser('some-user')
|
||||||
|
expect(subscription).to.be.null
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return null if the account doesn't have subscriptions", async function () {
|
||||||
|
this.client.listAccountSubscriptions.returns({
|
||||||
|
each: async function* () {},
|
||||||
|
})
|
||||||
|
const subscription =
|
||||||
|
await this.RecurlyClient.promises.getSubscriptionForUser('some-user')
|
||||||
|
expect(subscription).to.be.null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the subscription if the account has one subscription', async function () {
|
||||||
|
const recurlySubscription = this.recurlySubscription
|
||||||
|
this.client.listAccountSubscriptions.returns({
|
||||||
|
each: async function* () {
|
||||||
|
yield recurlySubscription
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const subscription =
|
||||||
|
await this.RecurlyClient.promises.getSubscriptionForUser('some-user')
|
||||||
|
expect(subscription).to.deep.equal(this.subscription)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if the account has more than one subscription', async function () {
|
||||||
|
const recurlySubscription = this.recurlySubscription
|
||||||
|
this.client.listAccountSubscriptions.returns({
|
||||||
|
each: async function* () {
|
||||||
|
yield recurlySubscription
|
||||||
|
yield { another: 'subscription' }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
this.RecurlyClient.promises.getSubscriptionForUser('some-user')
|
||||||
|
).to.be.rejected
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('applySubscriptionChangeRequest', function () {
|
describe('applySubscriptionChangeRequest', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
this.client.createSubscriptionChange = sinon
|
this.client.createSubscriptionChange = sinon
|
||||||
|
|
|
@ -4,9 +4,11 @@ const SandboxedModule = require('sandboxed-module')
|
||||||
const { expect } = require('chai')
|
const { expect } = require('chai')
|
||||||
const Errors = require('../../../../app/src/Features/Subscription/Errors')
|
const Errors = require('../../../../app/src/Features/Subscription/Errors')
|
||||||
const {
|
const {
|
||||||
|
AI_ADD_ON_CODE,
|
||||||
RecurlySubscriptionChangeRequest,
|
RecurlySubscriptionChangeRequest,
|
||||||
RecurlySubscriptionChange,
|
RecurlySubscriptionChange,
|
||||||
RecurlySubscription,
|
RecurlySubscription,
|
||||||
|
RecurlySubscriptionAddOnUpdate,
|
||||||
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
|
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
|
||||||
|
|
||||||
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
|
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
|
||||||
|
@ -16,6 +18,7 @@ describe('RecurlyEntities', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
this.Settings = {
|
this.Settings = {
|
||||||
plans: [
|
plans: [
|
||||||
|
{ planCode: 'assistant-annual', price_in_cents: 5900 },
|
||||||
{ planCode: 'cheap-plan', price_in_cents: 500 },
|
{ planCode: 'cheap-plan', price_in_cents: 500 },
|
||||||
{ planCode: 'regular-plan', price_in_cents: 1000 },
|
{ planCode: 'regular-plan', price_in_cents: 1000 },
|
||||||
{ planCode: 'premium-plan', price_in_cents: 2000 },
|
{ planCode: 'premium-plan', price_in_cents: 2000 },
|
||||||
|
@ -92,6 +95,67 @@ describe('RecurlyEntities', function () {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves the AI add-on on upgrades', function () {
|
||||||
|
const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
|
||||||
|
this.addOn.code = AI_ADD_ON_CODE
|
||||||
|
const changeRequest =
|
||||||
|
this.subscription.getRequestForPlanChange('premium-plan')
|
||||||
|
expect(changeRequest).to.deep.equal(
|
||||||
|
new RecurlySubscriptionChangeRequest({
|
||||||
|
subscription: this.subscription,
|
||||||
|
timeframe: 'now',
|
||||||
|
planCode: 'premium-plan',
|
||||||
|
addOnUpdates: [
|
||||||
|
new RecurlySubscriptionAddOnUpdate({
|
||||||
|
code: AI_ADD_ON_CODE,
|
||||||
|
quantity: 1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the AI add-on on downgrades', function () {
|
||||||
|
const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
|
||||||
|
this.addOn.code = AI_ADD_ON_CODE
|
||||||
|
const changeRequest =
|
||||||
|
this.subscription.getRequestForPlanChange('cheap-plan')
|
||||||
|
expect(changeRequest).to.deep.equal(
|
||||||
|
new RecurlySubscriptionChangeRequest({
|
||||||
|
subscription: this.subscription,
|
||||||
|
timeframe: 'term_end',
|
||||||
|
planCode: 'cheap-plan',
|
||||||
|
addOnUpdates: [
|
||||||
|
new RecurlySubscriptionAddOnUpdate({
|
||||||
|
code: AI_ADD_ON_CODE,
|
||||||
|
quantity: 1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the AI add-on on upgrades from the standalone AI plan', function () {
|
||||||
|
const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
|
||||||
|
this.subscription.planCode = 'assistant-annual'
|
||||||
|
this.subscription.addOns = []
|
||||||
|
const changeRequest =
|
||||||
|
this.subscription.getRequestForPlanChange('cheap-plan')
|
||||||
|
expect(changeRequest).to.deep.equal(
|
||||||
|
new RecurlySubscriptionChangeRequest({
|
||||||
|
subscription: this.subscription,
|
||||||
|
timeframe: 'term_end',
|
||||||
|
planCode: 'cheap-plan',
|
||||||
|
addOnUpdates: [
|
||||||
|
new RecurlySubscriptionAddOnUpdate({
|
||||||
|
code: AI_ADD_ON_CODE,
|
||||||
|
quantity: 1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getRequestForAddOnPurchase()', function () {
|
describe('getRequestForAddOnPurchase()', function () {
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
export type SubscriptionChangePreview = {
|
export type SubscriptionChangePreview = {
|
||||||
change: SubscriptionChange
|
change: SubscriptionChangeDescription
|
||||||
currency: string
|
currency: string
|
||||||
paymentMethod: string
|
paymentMethod: string
|
||||||
immediateCharge: number
|
immediateCharge: number
|
||||||
|
nextPlan: {
|
||||||
|
annual: boolean
|
||||||
|
}
|
||||||
nextInvoice: {
|
nextInvoice: {
|
||||||
date: string
|
date: string
|
||||||
plan: {
|
plan: {
|
||||||
|
@ -27,7 +30,7 @@ type AddOn = {
|
||||||
amount: number
|
amount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubscriptionChange = AddOnPurchase
|
export type SubscriptionChangeDescription = AddOnPurchase | PremiumSubscription
|
||||||
|
|
||||||
type AddOnPurchase = {
|
type AddOnPurchase = {
|
||||||
type: 'add-on-purchase'
|
type: 'add-on-purchase'
|
||||||
|
@ -36,3 +39,11 @@ type AddOnPurchase = {
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PremiumSubscription = {
|
||||||
|
type: 'premium-subscription'
|
||||||
|
plan: {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue