diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index e04f97748c..44a5c5600e 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -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} + */ +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} + */ +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} + */ +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, }, } diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/RecurlyEntities.js index af88324172..a488b4578a 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEntities.js +++ b/services/web/app/src/Features/Subscription/RecurlyEntities.js @@ -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, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 46f3a8613c..a45682807b 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -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} - */ 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} - */ 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: { diff --git a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js b/services/web/app/src/Features/Subscription/SubscriptionFormatters.js index 91f5d127f3..71df8c69b1 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionFormatters.js +++ b/services/web/app/src/Features/Subscription/SubscriptionFormatters.js @@ -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 = { diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 1903cabc27..777ccffa9e 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -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} + */ +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} 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, }, diff --git a/services/web/app/src/Features/Subscription/SubscriptionHelper.js b/services/web/app/src/Features/Subscription/SubscriptionHelper.js index b31a8bfa06..23220185df 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHelper.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHelper.js @@ -9,7 +9,7 @@ function shouldPlanChangeAtTermEnd(oldPlan, newPlan) { } /** - * @import { CurrencyCode } from '../../../../frontend/js/shared/utils/currency' + * @import { CurrencyCode } from '../../../../types/currency-code' */ /** diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index fd58b69639..4a4e4516fd 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -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(), diff --git a/services/web/app/src/Features/Subscription/types.ts b/services/web/app/src/Features/Subscription/types.ts new file mode 100644 index 0000000000..b4989c49ea --- /dev/null +++ b/services/web/app/src/Features/Subscription/types.ts @@ -0,0 +1,3 @@ +import { PaypalPaymentMethod, CreditCardPaymentMethod } from './RecurlyEntities' + +export type PaymentMethod = PaypalPaymentMethod | CreditCardPaymentMethod diff --git a/services/web/app/src/util/currency.js b/services/web/app/src/util/currency.js index c4b3eaacc1..774651cfa0 100644 --- a/services/web/app/src/util/currency.js +++ b/services/web/app/src/util/currency.js @@ -3,7 +3,7 @@ */ /** - * @import { CurrencyCode } from '@/shared/utils/currency' + * @import { CurrencyCode } from '../../../types/currency-code' */ /** diff --git a/services/web/app/views/subscriptions/preview-change.pug b/services/web/app/views/subscriptions/preview-change.pug new file mode 100644 index 0000000000..ab70d2d6b6 --- /dev/null +++ b/services/web/app/views/subscriptions/preview-change.pug @@ -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 diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 12c8acaa37..196d947149 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/plans/utils/group-plan-pricing.js b/services/web/frontend/js/features/plans/utils/group-plan-pricing.js index 1a00209ed2..53959d7ecf 100644 --- a/services/web/frontend/js/features/plans/utils/group-plan-pricing.js +++ b/services/web/frontend/js/features/plans/utils/group-plan-pricing.js @@ -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' diff --git a/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx b/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx new file mode 100644 index 0000000000..157cc735f9 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx @@ -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 ( + + + +
+ {preview.change.type === 'add-on-purchase' && ( +

+ {t('add_add_on_to_your_plan', { + addOnName: preview.change.addOn.name, + })} +

+ )} +
+

{t('payment_summary')}

+ + + {t('due_today')}: + + + + {formatCurrencyLocalized( + preview.immediateCharge, + preview.currency + )} + + + + +
+ +
+ {t('future_payments')}: +
+ + + {preview.nextInvoice.plan.name} + + {formatCurrencyLocalized( + preview.nextInvoice.plan.amount, + preview.currency + )} + + + + {preview.nextInvoice.addOns.map(addOn => ( + + + {addOn.name} + {addOn.quantity > 1 ? ` ×${addOn.quantity}` : ''} + + + {formatCurrencyLocalized(addOn.amount, preview.currency)} + + + ))} + + {preview.nextInvoice.tax.rate > 0 && ( + + + {t('vat')} {preview.nextInvoice.tax.rate * 100}% + + + {formatCurrencyLocalized( + preview.nextInvoice.tax.amount, + preview.currency + )} + + + )} + + + {t('total_per_month')} + + {formatCurrencyLocalized( + preview.nextInvoice.total, + preview.currency + )} + + +
+ +
+ }} + shouldUnescape + tOptions={{ interpolation: { escapeValue: true } }} + />{' '} + }} + shouldUnescape + tOptions={{ interpolation: { escapeValue: true } }} + /> +
+ +
+ +
+
+ +
+
+ ) +} + +export default PreviewSubscriptionChange diff --git a/services/web/frontend/js/pages/user/subscription/preview-change.tsx b/services/web/frontend/js/pages/user/subscription/preview-change.tsx new file mode 100644 index 0000000000..0f3804ecdc --- /dev/null +++ b/services/web/frontend/js/pages/user/subscription/preview-change.tsx @@ -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(, element) +} diff --git a/services/web/frontend/js/shared/utils/currency.ts b/services/web/frontend/js/shared/utils/currency.ts index 6b0dbb72a8..4fe3fef8b6 100644 --- a/services/web/frontend/js/shared/utils/currency.ts +++ b/services/web/frontend/js/shared/utils/currency.ts @@ -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 } diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index e9ed9cef37..8a9d11778b 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -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 diff --git a/services/web/frontend/stylesheets/app/subscription.less b/services/web/frontend/stylesheets/app/subscription.less index 3443624876..f473879c53 100644 --- a/services/web/frontend/stylesheets/app/subscription.less +++ b/services/web/frontend/stylesheets/app/subscription.less @@ -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; + } +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 313cf32eff..4133731b72 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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", "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 __date__.", "the_original_text_has_changed": "The original text has changed, so this suggestion can’t be applied", + "the_payment_method_used_is": "The payment method used is __paymentMethod__.", "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. It’s 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.", diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 43c6bf9651..9e861fead3 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -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) }) }) diff --git a/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js b/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js index 989b71c8c4..aed3951ed3 100644 --- a/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js +++ b/services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js @@ -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) + }) + }) + }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js index 699d121c35..87fde5700b 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js @@ -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, }) diff --git a/services/web/types/currency-code.ts b/services/web/types/currency-code.ts new file mode 100644 index 0000000000..f3876dc932 --- /dev/null +++ b/services/web/types/currency-code.ts @@ -0,0 +1,18 @@ +export type CurrencyCode = + | 'AUD' + | 'BRL' + | 'CAD' + | 'CHF' + | 'CLP' + | 'COP' + | 'DKK' + | 'EUR' + | 'GBP' + | 'INR' + | 'MXN' + | 'NOK' + | 'NZD' + | 'PEN' + | 'SEK' + | 'SGD' + | 'USD' diff --git a/services/web/types/subscription/subscription-change-preview.ts b/services/web/types/subscription/subscription-change-preview.ts new file mode 100644 index 0000000000..4b0b83bbb5 --- /dev/null +++ b/services/web/types/subscription/subscription-change-preview.ts @@ -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 + } +}