mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #21556 from overleaf/em-subscription-change-interstitial
Add-on purchase preview page GitOrigin-RevId: 660e39a94e6112af020ea783d6acf01a19432605
This commit is contained in:
parent
bc1e3dacda
commit
29be4f66d4
23 changed files with 708 additions and 99 deletions
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
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 {
|
||||
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: {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -9,7 +9,7 @@ function shouldPlanChangeAtTermEnd(oldPlan, newPlan) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @import { CurrencyCode } from '../../../../frontend/js/shared/utils/currency'
|
||||
* @import { CurrencyCode } from '../../../../types/currency-code'
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
|
@ -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(),
|
||||
|
|
3
services/web/app/src/Features/Subscription/types.ts
Normal file
3
services/web/app/src/Features/Subscription/types.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { PaypalPaymentMethod, CreditCardPaymentMethod } from './RecurlyEntities'
|
||||
|
||||
export type PaymentMethod = PaypalPaymentMethod | CreditCardPaymentMethod
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
/**
|
||||
* @import { CurrencyCode } from '@/shared/utils/currency'
|
||||
* @import { CurrencyCode } from '../../../types/currency-code'
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
11
services/web/app/views/subscriptions/preview-change.pug
Normal file
11
services/web/app/views/subscriptions/preview-change.pug
Normal 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
|
|
@ -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": "",
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 can’t 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. 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.",
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
18
services/web/types/currency-code.ts
Normal file
18
services/web/types/currency-code.ts
Normal 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'
|
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue