Merge pull request #21556 from overleaf/em-subscription-change-interstitial

Add-on purchase preview page

GitOrigin-RevId: 660e39a94e6112af020ea783d6acf01a19432605
This commit is contained in:
Eric Mc Sween 2024-11-06 08:09:28 -05:00 committed by Copybot
parent bc1e3dacda
commit 29be4f66d4
23 changed files with 708 additions and 99 deletions

View file

@ -9,10 +9,15 @@ const UserGetter = require('../User/UserGetter')
const {
RecurlySubscription,
RecurlySubscriptionAddOn,
RecurlySubscriptionChange,
PaypalPaymentMethod,
CreditCardPaymentMethod,
RecurlyAddOn,
} = require('./RecurlyEntities')
/**
* @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
* @import { PaymentMethod } from './types'
*/
const recurlySettings = Settings.apis.recurly
@ -59,7 +64,7 @@ async function createAccountForUserId(userId) {
*/
async function getSubscription(subscriptionId) {
const subscription = await client.getSubscription(`uuid-${subscriptionId}`)
return makeSubscription(subscription)
return subscriptionFromApi(subscription)
}
/**
@ -68,36 +73,35 @@ async function getSubscription(subscriptionId) {
* @param {RecurlySubscriptionChangeRequest} changeRequest
*/
async function applySubscriptionChangeRequest(changeRequest) {
/** @type {recurly.SubscriptionChangeCreate} */
const body = {
timeframe: changeRequest.timeframe,
}
if (changeRequest.planCode != null) {
body.planCode = changeRequest.planCode
}
if (changeRequest.addOnUpdates != null) {
body.addOns = changeRequest.addOnUpdates.map(addOnUpdate => {
/** @type {recurly.SubscriptionAddOnUpdate} */
const update = { code: addOnUpdate.code }
if (addOnUpdate.quantity != null) {
update.quantity = addOnUpdate.quantity
}
if (addOnUpdate.unitPrice != null) {
update.unitAmount = addOnUpdate.unitPrice
}
return update
})
}
const body = subscriptionChangeRequestToApi(changeRequest)
const change = await client.createSubscriptionChange(
`uuid-${changeRequest.subscriptionId}`,
`uuid-${changeRequest.subscription.id}`,
body
)
logger.debug(
{ subscriptionId: changeRequest.subscriptionId, changeId: change.id },
{ subscriptionId: changeRequest.subscription.id, changeId: change.id },
'created subscription change'
)
}
/**
* Preview a subscription change
*
* @param {RecurlySubscriptionChangeRequest} changeRequest
* @return {Promise<RecurlySubscriptionChange>}
*/
async function previewSubscriptionChange(changeRequest) {
const body = subscriptionChangeRequestToApi(changeRequest)
const subscriptionChange = await client.previewSubscriptionChange(
`uuid-${changeRequest.subscription.id}`,
body
)
return subscriptionChangeFromApi(
changeRequest.subscription,
subscriptionChange
)
}
async function removeSubscriptionChange(subscriptionId) {
const removed = await client.removeSubscriptionChange(subscriptionId)
logger.debug({ subscriptionId }, 'removed pending subscription change')
@ -131,6 +135,32 @@ async function cancelSubscriptionByUuid(subscriptionUuid) {
}
}
/**
* Get the payment method for the given user
*
* @param {string} userId
* @return {Promise<PaymentMethod>}
*/
async function getPaymentMethod(userId) {
const billingInfo = await client.getBillingInfo(`code-${userId}`)
return paymentMethodFromApi(billingInfo)
}
/**
* Get the configuration for a given add-on
*
* @param {string} planCode
* @param {string} addOnCode
* @return {Promise<RecurlyAddOn>}
*/
async function getAddOn(planCode, addOnCode) {
const addOn = await client.getPlanAddOn(
`code-${planCode}`,
`code-${addOnCode}`
)
return addOnFromApi(addOn)
}
function subscriptionIsCanceledOrExpired(subscription) {
const state = subscription?.recurlyStatus?.state
return state === 'canceled' || state === 'expired'
@ -142,7 +172,7 @@ function subscriptionIsCanceledOrExpired(subscription) {
* @param {recurly.Subscription} subscription
* @return {RecurlySubscription}
*/
function makeSubscription(subscription) {
function subscriptionFromApi(subscription) {
if (
subscription.uuid == null ||
subscription.plan == null ||
@ -153,7 +183,9 @@ function makeSubscription(subscription) {
subscription.unitAmount == null ||
subscription.subtotal == null ||
subscription.total == null ||
subscription.currency == null
subscription.currency == null ||
subscription.currentPeriodStartedAt == null ||
subscription.currentPeriodEndsAt == null
) {
throw new OError('Invalid Recurly subscription', { subscription })
}
@ -163,12 +195,14 @@ function makeSubscription(subscription) {
planCode: subscription.plan.code,
planName: subscription.plan.name,
planPrice: subscription.unitAmount,
addOns: (subscription.addOns ?? []).map(makeSubscriptionAddOn),
addOns: (subscription.addOns ?? []).map(subscriptionAddOnFromApi),
subtotal: subscription.subtotal,
taxRate: subscription.taxInfo?.rate ?? 0,
taxAmount: subscription.tax ?? 0,
total: subscription.total,
currency: subscription.currency,
periodStart: subscription.currentPeriodStartedAt,
periodEnd: subscription.currentPeriodEndsAt,
})
}
@ -178,7 +212,7 @@ function makeSubscription(subscription) {
* @param {recurly.SubscriptionAddOn} addOn
* @return {RecurlySubscriptionAddOn}
*/
function makeSubscriptionAddOn(addOn) {
function subscriptionAddOnFromApi(addOn) {
if (
addOn.addOn == null ||
addOn.addOn.code == null ||
@ -196,27 +230,136 @@ function makeSubscriptionAddOn(addOn) {
})
}
/**
* Build a RecurlySubscriptionChange from Recurly API data
*
* @param {RecurlySubscription} subscription - the current subscription
* @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API
* @return {RecurlySubscriptionChange}
*/
function subscriptionChangeFromApi(subscription, subscriptionChange) {
if (
subscriptionChange.plan == null ||
subscriptionChange.plan.code == null ||
subscriptionChange.plan.name == null ||
subscriptionChange.unitAmount == null
) {
throw new OError('Invalid Recurly subscription change', {
subscriptionChange,
})
}
const nextAddOns = (subscriptionChange.addOns ?? []).map(
subscriptionAddOnFromApi
)
return new RecurlySubscriptionChange({
subscription,
nextPlanCode: subscriptionChange.plan.code,
nextPlanName: subscriptionChange.plan.name,
nextPlanPrice: subscriptionChange.unitAmount,
nextAddOns,
immediateCharge:
subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0,
})
}
/**
* Returns a payment method from Recurly API data
*
* @param {recurly.BillingInfo} billingInfo
* @return {PaymentMethod}
*/
function paymentMethodFromApi(billingInfo) {
if (billingInfo.paymentMethod == null) {
throw new OError('Invalid Recurly billing info', { billingInfo })
}
const paymentMethod = billingInfo.paymentMethod
if (paymentMethod.billingAgreementId != null) {
return new PaypalPaymentMethod()
}
if (paymentMethod.cardType == null || paymentMethod.lastFour == null) {
throw new OError('Invalid Recurly billing info', { billingInfo })
}
return new CreditCardPaymentMethod({
cardType: paymentMethod.cardType,
lastFour: paymentMethod.lastFour,
})
}
/**
* Build a RecurlyAddOn from Recurly API data
*
* @param {recurly.AddOn} addOn
* @return {RecurlyAddOn}
*/
function addOnFromApi(addOn) {
if (addOn.code == null || addOn.name == null) {
throw new OError('Invalid Recurly add-on', { addOn })
}
return new RecurlyAddOn({
code: addOn.code,
name: addOn.name,
})
}
/**
* Build an API request from a RecurlySubscriptionChangeRequest
*
* @param {RecurlySubscriptionChangeRequest} changeRequest
* @return {recurly.SubscriptionChangeCreate}
*/
function subscriptionChangeRequestToApi(changeRequest) {
/** @type {recurly.SubscriptionChangeCreate} */
const requestBody = {
timeframe: changeRequest.timeframe,
}
if (changeRequest.planCode != null) {
requestBody.planCode = changeRequest.planCode
}
if (changeRequest.addOnUpdates != null) {
requestBody.addOns = changeRequest.addOnUpdates.map(addOnUpdate => {
/** @type {recurly.SubscriptionAddOnUpdate} */
const update = { code: addOnUpdate.code }
if (addOnUpdate.quantity != null) {
update.quantity = addOnUpdate.quantity
}
if (addOnUpdate.unitPrice != null) {
update.unitAmount = addOnUpdate.unitPrice
}
return update
})
}
return requestBody
}
module.exports = {
errors: recurly.errors,
getAccountForUserId: callbackify(getAccountForUserId),
createAccountForUserId: callbackify(createAccountForUserId),
getSubscription: callbackify(getSubscription),
previewSubscriptionChange: callbackify(previewSubscriptionChange),
applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
removeSubscriptionChange: callbackify(removeSubscriptionChange),
removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid),
cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
getPaymentMethod: callbackify(getPaymentMethod),
getAddOn: callbackify(getAddOn),
subscriptionIsCanceledOrExpired,
promises: {
getSubscription,
getAccountForUserId,
createAccountForUserId,
previewSubscriptionChange,
applySubscriptionChangeRequest,
removeSubscriptionChange,
removeSubscriptionChangeByUuid,
reactivateSubscriptionByUuid,
cancelSubscriptionByUuid,
getPaymentMethod,
getAddOn,
},
}

View file

@ -19,6 +19,8 @@ class RecurlySubscription {
* @param {number} [props.taxAmount]
* @param {string} props.currency
* @param {number} props.total
* @param {Date} props.periodStart
* @param {Date} props.periodEnd
*/
constructor(props) {
this.id = props.id
@ -32,6 +34,8 @@ class RecurlySubscription {
this.taxAmount = props.taxAmount ?? 0
this.currency = props.currency
this.total = props.total
this.periodStart = props.periodStart
this.periodEnd = props.periodEnd
}
hasAddOn(code) {
@ -60,7 +64,7 @@ class RecurlySubscription {
)
const timeframe = changeAtTermEnd ? 'term_end' : 'now'
return new RecurlySubscriptionChangeRequest({
subscriptionId: this.id,
subscription: this,
timeframe,
planCode,
})
@ -87,7 +91,7 @@ class RecurlySubscription {
const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
addOnUpdates.push(new RecurlySubscriptionAddOnUpdate({ code, quantity }))
return new RecurlySubscriptionChangeRequest({
subscriptionId: this.id,
subscription: this,
timeframe: 'now',
addOnUpdates,
})
@ -115,13 +119,16 @@ class RecurlySubscription {
.filter(addOn => addOn.code !== code)
.map(addOn => addOn.toAddOnUpdate())
return new RecurlySubscriptionChangeRequest({
subscriptionId: this.id,
subscription: this,
timeframe: 'term_end',
addOnUpdates,
})
}
}
/**
* An add-on attached to a subscription
*/
class RecurlySubscriptionAddOn {
/**
* @param {object} props
@ -135,10 +142,7 @@ class RecurlySubscriptionAddOn {
this.name = props.name
this.quantity = props.quantity
this.unitPrice = props.unitPrice
}
get preTaxTotal() {
return this.quantity * this.unitPrice
this.preTaxTotal = this.quantity * this.unitPrice
}
/**
@ -156,7 +160,7 @@ class RecurlySubscriptionAddOn {
class RecurlySubscriptionChangeRequest {
/**
* @param {object} props
* @param {string} props.subscriptionId
* @param {RecurlySubscription} props.subscription
* @param {"now" | "term_end"} props.timeframe
* @param {string} [props.planCode]
* @param {RecurlySubscriptionAddOnUpdate[]} [props.addOnUpdates]
@ -165,7 +169,7 @@ class RecurlySubscriptionChangeRequest {
if (props.planCode == null && props.addOnUpdates == null) {
throw new OError('Invalid RecurlySubscriptionChangeRequest', { props })
}
this.subscriptionId = props.subscriptionId
this.subscription = props.subscription
this.timeframe = props.timeframe
this.planCode = props.planCode ?? null
this.addOnUpdates = props.addOnUpdates ?? null
@ -186,9 +190,83 @@ class RecurlySubscriptionAddOnUpdate {
}
}
class RecurlySubscriptionChange {
/**
* @param {object} props
* @param {RecurlySubscription} props.subscription
* @param {string} props.nextPlanCode
* @param {string} props.nextPlanName
* @param {number} props.nextPlanPrice
* @param {RecurlySubscriptionAddOn[]} props.nextAddOns
* @param {number} [props.immediateCharge]
*/
constructor(props) {
this.subscription = props.subscription
this.nextPlanCode = props.nextPlanCode
this.nextPlanName = props.nextPlanName
this.nextPlanPrice = props.nextPlanPrice
this.nextAddOns = props.nextAddOns
this.immediateCharge = props.immediateCharge ?? 0
this.subtotal = this.nextPlanPrice
for (const addOn of this.nextAddOns) {
this.subtotal += addOn.preTaxTotal
}
this.tax = Math.round(this.subtotal * 100 * this.subscription.taxRate) / 100
this.total = this.subtotal + this.tax
}
getAddOn(addOnCode) {
return this.nextAddOns.find(addOn => addOn.code === addOnCode)
}
}
class PaypalPaymentMethod {
toString() {
return 'Paypal'
}
}
class CreditCardPaymentMethod {
/**
* @param {object} props
* @param {string} props.cardType
* @param {string} props.lastFour
*/
constructor(props) {
this.cardType = props.cardType
this.lastFour = props.lastFour
}
toString() {
return `${this.cardType} **** ${this.lastFour}`
}
}
/**
* An add-on configuration, independent of any subscription
*/
class RecurlyAddOn {
/**
* @param {object} props
* @param {string} props.code
* @param {string} props.name
*/
constructor(props) {
this.code = props.code
this.name = props.name
}
}
module.exports = {
RecurlySubscription,
RecurlySubscriptionAddOn,
RecurlySubscriptionChange,
RecurlySubscriptionChangeRequest,
RecurlySubscriptionAddOnUpdate,
PaypalPaymentMethod,
CreditCardPaymentMethod,
RecurlyAddOn,
}

View file

@ -1,3 +1,5 @@
// @ts-check
const SessionManager = require('../Authentication/SessionManager')
const SubscriptionHandler = require('./SubscriptionHandler')
const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
@ -25,8 +27,13 @@ const { formatCurrencyLocalized } = require('../../util/currency')
const SubscriptionFormatters = require('./SubscriptionFormatters')
const HttpErrorHandler = require('../Errors/HttpErrorHandler')
const { URLSearchParams } = require('url')
const RecurlyClient = require('./RecurlyClient')
const AI_ADDON_CODE = 'assistant'
/**
* @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview'
*/
const AI_ADDON_CODES = ['assistant', 'assistant-annual']
const groupPlanModalOptions = Settings.groupPlanModalOptions
const validGroupPlanModalOptions = {
@ -222,11 +229,6 @@ function formatGroupPlansDataForDash() {
}
}
/**
* @param {import('express').Request} req
* @param {import('express').Response} res
* @returns {Promise<void>}
*/
async function userSubscriptionPage(req, res) {
const user = SessionManager.getSessionUser(req.session)
@ -402,11 +404,6 @@ async function interstitialPaymentPage(req, res) {
}
}
/**
* @param {import('express').Request} req
* @param {import('express').Response} res
* @returns {Promise<void>}
*/
async function successfulSubscription(req, res) {
const user = SessionManager.getSessionUser(req.session)
const localCcyAssignment = await SplitTestHandler.promises.getAssignment(
@ -489,13 +486,81 @@ function cancelV1Subscription(req, res, next) {
})
}
async function previewAddonPurchase(req, res) {
const userId = SessionManager.getLoggedInUserId(req.session)
const addOnCode = req.params.addOnCode
if (!AI_ADDON_CODES.includes(addOnCode)) {
return HttpErrorHandler.notFound(req, res, `Unknown add-on: ${addOnCode}`)
}
const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
let subscriptionChange
try {
subscriptionChange =
await SubscriptionHandler.promises.previewAddonPurchase(userId, addOnCode)
} catch (err) {
if (err instanceof DuplicateAddOnError) {
return HttpErrorHandler.badRequest(
req,
res,
`Subscription already has add-on "${addOnCode}"`
)
}
throw err
}
const subscription = subscriptionChange.subscription
const addOn = await RecurlyClient.promises.getAddOn(
subscription.planCode,
addOnCode
)
/** @type {SubscriptionChangePreview} */
const changePreview = {
change: {
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,
},
}
res.render('subscriptions/preview-change', { changePreview })
}
async function purchaseAddon(req, res, next) {
const user = SessionManager.getSessionUser(req.session)
const addOnCode = req.params.addOnCode
// currently we only support having a quantity of 1
const quantity = 1
// currently we only support one add-on, the Ai add-on
if (addOnCode !== AI_ADDON_CODE) {
if (!AI_ADDON_CODES.includes(addOnCode)) {
return res.sendStatus(404)
}
@ -512,10 +577,12 @@ async function purchaseAddon(req, res, next) {
{ addon: addOnCode }
)
} else {
OError.tag(err, 'something went wrong purchasing add-ons', {
user_id: user._id,
addOnCode,
})
if (err instanceof Error) {
OError.tag(err, 'something went wrong purchasing add-ons', {
user_id: user._id,
addOnCode,
})
}
return next(err)
}
}
@ -525,7 +592,7 @@ async function removeAddon(req, res, next) {
const user = SessionManager.getSessionUser(req.session)
const addOnCode = req.params.addOnCode
if (addOnCode !== AI_ADDON_CODE) {
if (!AI_ADDON_CODES.includes(addOnCode)) {
return res.sendStatus(404)
}
@ -543,10 +610,12 @@ async function removeAddon(req, res, next) {
{ addon: addOnCode }
)
} else {
OError.tag(err, 'something went wrong removing add-ons', {
user_id: user._id,
addOnCode,
})
if (err instanceof Error) {
OError.tag(err, 'something went wrong removing add-ons', {
user_id: user._id,
addOnCode,
})
}
return next(err)
}
}
@ -839,6 +908,7 @@ module.exports = {
refreshUserFeatures: expressify(refreshUserFeatures),
redirectToHostedPage: expressify(redirectToHostedPage),
plansBanners: _plansBanners,
previewAddonPurchase: expressify(previewAddonPurchase),
purchaseAddon,
removeAddon,
promises: {

View file

@ -2,7 +2,7 @@ const dateformat = require('dateformat')
const { formatCurrencyLocalized } = require('../../util/currency')
/**
* @import { CurrencyCode } from '@/shared/utils/currency'
* @import { CurrencyCode } from '../../../../types/currency-code'
*/
const currencySymbols = {

View file

@ -5,6 +5,7 @@ const RecurlyClient = require('./RecurlyClient')
const { User } = require('../../models/User')
const logger = require('@overleaf/logger')
const SubscriptionUpdater = require('./SubscriptionUpdater')
const SubscriptionLocator = require('./SubscriptionLocator')
const LimitationsManager = require('./LimitationsManager')
const EmailHandler = require('../Email/EmailHandler')
const { callbackify } = require('@overleaf/promise-utils')
@ -12,7 +13,8 @@ const UserUpdater = require('../User/UserUpdater')
const { NoRecurlySubscriptionError } = require('./Errors')
/**
* @import { RecurlySubscription } from './RecurlyEntities'
* @import recurly from 'recurly'
* @import { RecurlySubscription, RecurlySubscriptionChange } from './RecurlyEntities'
*/
async function validateNoSubscriptionInRecurly(userId) {
@ -62,6 +64,11 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) {
)
}
/**
* @param user
* @param planCode
* @param couponCode
*/
async function updateSubscription(user, planCode, couponCode) {
let hasSubscription = false
let subscription
@ -105,6 +112,9 @@ async function updateSubscription(user, planCode, couponCode) {
await syncSubscription({ uuid: recurlySubscriptionId }, user._id)
}
/**
* @param user
*/
async function cancelPendingSubscriptionChange(user) {
const { hasSubscription, subscription } =
await LimitationsManager.promises.userHasV2Subscription(user)
@ -116,6 +126,9 @@ async function cancelPendingSubscriptionChange(user) {
}
}
/**
* @param user
*/
async function cancelSubscription(user) {
try {
const { hasSubscription, subscription } =
@ -144,6 +157,9 @@ async function cancelSubscription(user) {
}
}
/**
* @param user
*/
async function reactivateSubscription(user) {
try {
const { hasSubscription, subscription } =
@ -174,6 +190,10 @@ async function reactivateSubscription(user) {
}
}
/**
* @param recurlySubscription
* @param requesterData
*/
async function syncSubscription(recurlySubscription, requesterData) {
const storedSubscription = await RecurlyWrapper.promises.getSubscription(
recurlySubscription.uuid,
@ -195,10 +215,14 @@ async function syncSubscription(recurlySubscription, requesterData) {
)
}
// attempt to collect past due invoice for customer. Only do that when a) the
// customer is using Paypal and b) there is only one past due invoice.
// This is used because Recurly doesn't always attempt collection of paast due
// invoices after Paypal billing info were updated.
/**
* attempt to collect past due invoice for customer. Only do that when a) the
* customer is using Paypal and b) there is only one past due invoice.
* This is used because Recurly doesn't always attempt collection of paast due
* invoices after Paypal billing info were updated.
*
* @param recurlyAccountCode
*/
async function attemptPaypalInvoiceCollection(recurlyAccountCode) {
const billingInfo =
await RecurlyWrapper.promises.getBillingInfo(recurlyAccountCode)
@ -259,6 +283,26 @@ async function _getSubscription(user) {
return currentSub
}
/**
* Preview the effect of purchasing an add-on
*
* @param {string} userId
* @param {string} addOnCode
* @return {Promise<RecurlySubscriptionChange>}
*/
async function previewAddonPurchase(userId, addOnCode) {
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.getRequestForAddOnPurchase(addOnCode)
const change =
await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
return change
}
async function purchaseAddon(user, addOnCode, quantity) {
const subscription = await _getSubscription(user)
const changeRequest = subscription.getRequestForAddOnPurchase(
@ -276,6 +320,21 @@ async function removeAddon(user, addOnCode) {
await syncSubscription({ uuid: subscription.id }, user._id)
}
/**
* Returns the Recurly UUID for the given user
*
* @param {string} userId
* @return {Promise<string | null>} the Recurly UUID
*/
async function getSubscriptionRecurlyId(userId) {
const subscription =
await SubscriptionLocator.promises.getUsersSubscription(userId)
if (subscription == null) {
return null
}
return subscription.recurlySubscription_id ?? null
}
module.exports = {
validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
createSubscription: callbackify(createSubscription),
@ -286,6 +345,7 @@ module.exports = {
syncSubscription: callbackify(syncSubscription),
attemptPaypalInvoiceCollection: callbackify(attemptPaypalInvoiceCollection),
extendTrial: callbackify(extendTrial),
previewAddonPurchase: callbackify(previewAddonPurchase),
purchaseAddon: callbackify(purchaseAddon),
removeAddon: callbackify(removeAddon),
promises: {
@ -298,6 +358,7 @@ module.exports = {
syncSubscription,
attemptPaypalInvoiceCollection,
extendTrial,
previewAddonPurchase,
purchaseAddon,
removeAddon,
},

View file

@ -9,7 +9,7 @@ function shouldPlanChangeAtTermEnd(oldPlan, newPlan) {
}
/**
* @import { CurrencyCode } from '../../../../frontend/js/shared/utils/currency'
* @import { CurrencyCode } from '../../../../types/currency-code'
*/
/**

View file

@ -120,6 +120,12 @@ export default {
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
SubscriptionController.updateSubscription
)
webRouter.get(
'/user/subscription/addon/:addOnCode/add',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
SubscriptionController.previewAddonPurchase
)
webRouter.post(
'/user/subscription/addon/:addOnCode/add',
AuthenticationController.requireLogin(),

View file

@ -0,0 +1,3 @@
import { PaypalPaymentMethod, CreditCardPaymentMethod } from './RecurlyEntities'
export type PaymentMethod = PaypalPaymentMethod | CreditCardPaymentMethod

View file

@ -3,7 +3,7 @@
*/
/**
* @import { CurrencyCode } from '@/shared/utils/currency'
* @import { CurrencyCode } from '../../../types/currency-code'
*/
/**

View file

@ -0,0 +1,11 @@
extends ../layout-marketing
block entrypointVar
- entrypoint = 'pages/user/subscription/preview-change'
block append meta
meta(name="ol-subscriptionChangePreview" data-type="json" content=changePreview)
block content
main.content.content-alt#main-content
#subscription-preview-change

View file

@ -48,6 +48,7 @@
"active": "",
"add": "",
"add_a_recovery_email_address": "",
"add_add_on_to_your_plan": "",
"add_additional_certificate": "",
"add_affiliation": "",
"add_another_address_line": "",
@ -522,6 +523,7 @@
"full_doc_history": "",
"full_project_search": "",
"full_width": "",
"future_payments": "",
"generate_token": "",
"generic_if_problem_continues_contact_us": "",
"generic_linked_file_compile_error": "",
@ -1027,6 +1029,7 @@
"paste_options": "",
"paste_with_formatting": "",
"paste_without_formatting": "",
"pay_now": "",
"payment_provider_unreachable_error": "",
"payment_summary": "",
"pdf_compile_in_progress_error": "",
@ -1522,7 +1525,9 @@
"the_following_files_and_folders_already_exist_in_this_project": "",
"the_following_folder_already_exists_in_this_project": "",
"the_following_folder_already_exists_in_this_project_plural": "",
"the_next_payment_will_be_collected_on": "",
"the_original_text_has_changed": "",
"the_payment_method_used_is": "",
"the_target_folder_could_not_be_found": "",
"the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "",
"their_projects_will_be_transferred_to_another_user": "",

View file

@ -1,7 +1,7 @@
import getMeta from '../../../utils/meta'
/**
* @import { CurrencyCode } from '@/shared/utils/currency'
* @import { CurrencyCode } from '../../../../../types/currency-code'
*/
// plan: 'collaborator' or 'professional'

View file

@ -0,0 +1,120 @@
import { Grid, Row, Col, Button } from 'react-bootstrap'
import moment from 'moment'
import { useTranslation, Trans } from 'react-i18next'
import getMeta from '@/utils/meta'
import { formatCurrencyLocalized } from '@/shared/utils/currency'
function PreviewSubscriptionChange() {
const { t } = useTranslation()
const preview = getMeta('ol-subscriptionChangePreview')
return (
<Grid>
<Row>
<Col md={8} mdOffset={2}>
<div className="card p-5">
{preview.change.type === 'add-on-purchase' && (
<h1>
{t('add_add_on_to_your_plan', {
addOnName: preview.change.addOn.name,
})}
</h1>
)}
<div className="payment-summary-card mt-5">
<h3>{t('payment_summary')}</h3>
<Row>
<Col xs={9}>
<strong>{t('due_today')}:</strong>
</Col>
<Col xs={3} className="text-right">
<strong>
{formatCurrencyLocalized(
preview.immediateCharge,
preview.currency
)}
</strong>
</Col>
</Row>
<hr />
<div>
<strong>{t('future_payments')}:</strong>
</div>
<Row className="mt-1">
<Col xs={9}>{preview.nextInvoice.plan.name}</Col>
<Col xs={3} className="text-right">
{formatCurrencyLocalized(
preview.nextInvoice.plan.amount,
preview.currency
)}
</Col>
</Row>
{preview.nextInvoice.addOns.map(addOn => (
<Row className="mt-1" key={addOn.code}>
<Col xs={9}>
{addOn.name}
{addOn.quantity > 1 ? ` ×${addOn.quantity}` : ''}
</Col>
<Col xs={3} className="text-right">
{formatCurrencyLocalized(addOn.amount, preview.currency)}
</Col>
</Row>
))}
{preview.nextInvoice.tax.rate > 0 && (
<Row className="mt-1">
<Col xs={9}>
{t('vat')} {preview.nextInvoice.tax.rate * 100}%
</Col>
<Col xs={3} className="text-right">
{formatCurrencyLocalized(
preview.nextInvoice.tax.amount,
preview.currency
)}
</Col>
</Row>
)}
<Row className="mt-1">
<Col xs={9}>{t('total_per_month')}</Col>
<Col xs={3} className="text-right">
{formatCurrencyLocalized(
preview.nextInvoice.total,
preview.currency
)}
</Col>
</Row>
</div>
<div className="mt-5">
<Trans
i18nKey="the_next_payment_will_be_collected_on"
values={{ date: moment(preview.nextInvoice.date).format('LL') }}
components={{ strong: <strong /> }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>{' '}
<Trans
i18nKey="the_payment_method_used_is"
values={{ paymentMethod: preview.paymentMethod }}
components={{ strong: <strong /> }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</div>
<div className="mt-5">
<Button bsStyle="primary" bsSize="large">
{t('pay_now')}
</Button>
</div>
</div>
</Col>
</Row>
</Grid>
)
}
export default PreviewSubscriptionChange

View file

@ -0,0 +1,8 @@
import '@/marketing'
import ReactDOM from 'react-dom'
import PreviewSubscriptionChange from '@/features/subscription/components/preview-subscription-change/root'
const element = document.getElementById('subscription-preview-change')
if (element) {
ReactDOM.render(<PreviewSubscriptionChange />, element)
}

View file

@ -1,26 +1,11 @@
export type CurrencyCode =
| 'AUD'
| 'BRL'
| 'CAD'
| 'CHF'
| 'CLP'
| 'COP'
| 'DKK'
| 'EUR'
| 'GBP'
| 'INR'
| 'MXN'
| 'NOK'
| 'NZD'
| 'PEN'
| 'SEK'
| 'SGD'
| 'USD'
import getMeta from '@/utils/meta'
const DEFAULT_LOCALE = getMeta('ol-i18n')?.currentLangCode ?? 'en'
export function formatCurrencyLocalized(
amount: number,
currency: CurrencyCode,
locale: string,
currency: string,
locale: string = DEFAULT_LOCALE,
stripIfInteger = false
): string {
const options: Intl.NumberFormatOptions = { style: 'currency', currency }

View file

@ -47,6 +47,7 @@ import { PasswordStrengthOptions } from '../../../types/password-strength-option
import { Subscription as ProjectDashboardSubscription } from '../../../types/project/dashboard/subscription'
import { ThirdPartyIds } from '../../../types/third-party-ids'
import { Publisher } from '../../../types/subscription/dashboard/publisher'
import { SubscriptionChangePreview } from '../../../types/subscription/subscription-change-preview'
import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
import { FatFooterMetadata } from '@/features/ui/components/types/fat-footer-metadata'
export interface Meta {
@ -191,6 +192,7 @@ export interface Meta {
'ol-ssoDisabled': boolean
'ol-ssoErrorMessage': string
'ol-subscription': any // TODO: mixed types, split into two fields
'ol-subscriptionChangePreview': SubscriptionChangePreview
'ol-subscriptionId': string
'ol-suggestedLanguage': SuggestedLanguage | undefined
'ol-survey': Survey | undefined

View file

@ -286,3 +286,16 @@ a.row-link {
height: 20px;
width: 20px;
}
.payment-summary-card {
&:extend(.card-gray);
border-radius: @border-radius-large-new;
h3 {
margin-top: 0;
margin-bottom: @spacing-08;
font-family: @font-family-sans-serif;
font-weight: 600;
color: @content-secondary;
}
}

View file

@ -59,6 +59,7 @@
"active": "Active",
"add": "Add",
"add_a_recovery_email_address": "Add a recovery email address",
"add_add_on_to_your_plan": "Add __addOnName__ to your plan",
"add_additional_certificate": "Add another certificate",
"add_affiliation": "Add Affiliation",
"add_another_address_line": "Add another address line",
@ -748,6 +749,7 @@
"full_document_history": "Full document <0>history</0>",
"full_project_search": "Full Project Search",
"full_width": "Full width",
"future_payments": "Future payments",
"gallery": "Gallery",
"gallery_back_to_all": "Back to all __itemPlural__",
"gallery_find_more": "Find More __itemPlural__",
@ -1483,6 +1485,7 @@
"paste_options": "Paste options",
"paste_with_formatting": "Paste with formatting",
"paste_without_formatting": "Paste without formatting",
"pay_now": "Pay now",
"payment_method_accepted": "__paymentMethod__ accepted",
"payment_provider_unreachable_error": "Sorry, there was an error talking to our payment provider. Please try again in a few moments.\nIf you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them.",
"payment_summary": "Payment summary",
@ -2124,7 +2127,9 @@
"the_following_files_and_folders_already_exist_in_this_project": "The following files and folders already exist in this project:",
"the_following_folder_already_exists_in_this_project": "The following folder already exists in this project:",
"the_following_folder_already_exists_in_this_project_plural": "The following folders already exist in this project:",
"the_next_payment_will_be_collected_on": "The next payment will be collected on <strong>__date__</strong>.",
"the_original_text_has_changed": "The original text has changed, so this suggestion cant be applied",
"the_payment_method_used_is": "The payment method used is <strong>__paymentMethod__</strong>.",
"the_project_that_contains_this_file_is_not_shared_with_you": "The project that contains this file is not shared with you",
"the_requested_conversion_job_was_not_found": "The link to open this content on Overleaf specified a conversion job that could not be found. Its possible that the job has expired and needs to be run again. If this keeps happening for links on a particular site, please report this to them.",
"the_requested_publisher_was_not_found": "The link to open this content on Overleaf specified a publisher that could not be found. If this keeps happening for links on a particular site, please report this to them.",

View file

@ -32,6 +32,7 @@ describe('RecurlyClient', function () {
name: 'My Add-On',
quantity: 1,
unitPrice: 2,
preTaxTotal: 2,
}
this.subscription = {
@ -46,6 +47,8 @@ describe('RecurlyClient', function () {
taxRate: 0.1,
taxAmount: 1.5,
total: 16.5,
periodStart: new Date(),
periodEnd: new Date(),
}
this.recurlySubscription = {
@ -73,6 +76,8 @@ describe('RecurlyClient', function () {
tax: this.subscription.taxAmount,
total: this.subscription.total,
currency: this.subscription.currency,
currentPeriodStartedAt: this.subscription.periodStart,
currentPeriodEndsAt: this.subscription.periodEnd,
}
this.recurlySubscriptionChange = new recurly.SubscriptionChange()
@ -203,7 +208,7 @@ describe('RecurlyClient', function () {
it('handles plan changes', async function () {
await this.RecurlyClient.promises.applySubscriptionChangeRequest(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
subscription: this.subscription,
timeframe: 'now',
planCode: 'new-plan',
})
@ -217,7 +222,7 @@ describe('RecurlyClient', function () {
it('handles add-on changes', async function () {
await this.RecurlyClient.promises.applySubscriptionChangeRequest(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
new RecurlySubscriptionAddOnUpdate({
@ -240,10 +245,9 @@ describe('RecurlyClient', function () {
it('should throw any API errors', async function () {
this.client.createSubscriptionChange = sinon.stub().throws()
await expect(
this.RecurlyClient.promises.applySubscriptionChangeRequest(
this.subscription.id,
{}
)
this.RecurlyClient.promises.applySubscriptionChangeRequest({
subscription: this.subscription,
})
).to.eventually.be.rejectedWith(Error)
})
})

View file

@ -5,6 +5,8 @@ const { expect } = require('chai')
const Errors = require('../../../../app/src/Features/Subscription/Errors')
const {
RecurlySubscriptionChangeRequest,
RecurlySubscriptionChange,
RecurlySubscription,
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
@ -71,7 +73,7 @@ describe('RecurlyEntities', function () {
this.subscription.getRequestForPlanChange('premium-plan')
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
subscription: this.subscription,
timeframe: 'now',
planCode: 'premium-plan',
})
@ -84,7 +86,7 @@ describe('RecurlyEntities', function () {
this.subscription.getRequestForPlanChange('cheap-plan')
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
subscription: this.subscription,
timeframe: 'term_end',
planCode: 'cheap-plan',
})
@ -102,7 +104,7 @@ describe('RecurlyEntities', function () {
this.subscription.getRequestForAddOnPurchase('another-add-on')
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
new RecurlySubscriptionAddOnUpdate({
@ -133,7 +135,7 @@ describe('RecurlyEntities', function () {
)
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
subscription: this.subscription,
timeframe: 'term_end',
addOnUpdates: [],
})
@ -180,7 +182,7 @@ describe('RecurlyEntities', function () {
this.subscription.getRequestForAddOnPurchase('some-add-on')
expect(changeRequest).to.deep.equal(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.id,
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
new RecurlySubscriptionAddOnUpdate({
@ -203,4 +205,34 @@ describe('RecurlyEntities', function () {
})
})
})
describe('RecurlySubscriptionChange', function () {
describe('constructor', function () {
it('rounds the amounts when calculating the taxes', function () {
const subscription = new RecurlySubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'premium-plan',
planName: 'Premium plan',
planPrice: 10,
subtotal: 10,
taxRate: 0.15,
taxAmount: 1.5,
currency: 'USD',
total: 11.5,
periodStart: new Date(),
periodEnd: new Date(),
})
const change = new RecurlySubscriptionChange({
subscription,
nextPlanCode: 'promotional-plan',
nextPlanName: 'Promotial plan',
nextPlanPrice: 8.99,
nextAddOns: [],
})
expect(change.tax).to.equal(1.35)
expect(change.total).to.equal(10.34)
})
})
})
})

View file

@ -122,6 +122,12 @@ describe('SubscriptionHandler', function () {
},
}
this.SubscriptionLocator = {
promises: {
getUsersSubscription: sinon.stub().resolves(this.subscription),
},
}
this.EmailHandler = {
sendEmail: sinon.stub(),
sendDeferredEmail: sinon.stub(),
@ -142,6 +148,7 @@ describe('SubscriptionHandler', function () {
User: this.User,
},
'./SubscriptionUpdater': this.SubscriptionUpdater,
'./SubscriptionLocator': this.SubscriptionLocator,
'./LimitationsManager': this.LimitationsManager,
'../Email/EmailHandler': this.EmailHandler,
'../Analytics/AnalyticsManager': this.AnalyticsManager,
@ -267,7 +274,7 @@ describe('SubscriptionHandler', function () {
this.RecurlyClient.promises.applySubscriptionChangeRequest
).to.have.been.calledWith(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.recurlySubscription_id,
subscription: this.activeRecurlyClientSubscription,
timeframe: 'now',
planCode: this.plan_code,
})
@ -380,7 +387,7 @@ describe('SubscriptionHandler', function () {
this.RecurlyClient.promises.applySubscriptionChangeRequest
).to.be.calledWith(
new RecurlySubscriptionChangeRequest({
subscriptionId: this.subscription.recurlySubscription_id,
subscription: this.activeRecurlyClientSubscription,
timeframe: 'now',
planCode: this.plan_code,
})

View file

@ -0,0 +1,18 @@
export type CurrencyCode =
| 'AUD'
| 'BRL'
| 'CAD'
| 'CHF'
| 'CLP'
| 'COP'
| 'DKK'
| 'EUR'
| 'GBP'
| 'INR'
| 'MXN'
| 'NOK'
| 'NZD'
| 'PEN'
| 'SEK'
| 'SGD'
| 'USD'

View file

@ -0,0 +1,38 @@
export type SubscriptionChangePreview = {
change: SubscriptionChange
currency: string
paymentMethod: string
immediateCharge: number
nextInvoice: {
date: string
plan: {
name: string
amount: number
}
addOns: AddOn[]
subtotal: number
tax: {
rate: number
amount: number
}
total: number
}
}
type AddOn = {
code: string
name: string
quantity: number
unitAmount: number
amount: number
}
type SubscriptionChange = AddOnPurchase
type AddOnPurchase = {
type: 'add-on-purchase'
addOn: {
code: string
name: string
}
}