mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-14 20:40:17 -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 {
|
const {
|
||||||
RecurlySubscription,
|
RecurlySubscription,
|
||||||
RecurlySubscriptionAddOn,
|
RecurlySubscriptionAddOn,
|
||||||
|
RecurlySubscriptionChange,
|
||||||
|
PaypalPaymentMethod,
|
||||||
|
CreditCardPaymentMethod,
|
||||||
|
RecurlyAddOn,
|
||||||
} = require('./RecurlyEntities')
|
} = require('./RecurlyEntities')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
|
* @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
|
||||||
|
* @import { PaymentMethod } from './types'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const recurlySettings = Settings.apis.recurly
|
const recurlySettings = Settings.apis.recurly
|
||||||
|
@ -59,7 +64,7 @@ async function createAccountForUserId(userId) {
|
||||||
*/
|
*/
|
||||||
async function getSubscription(subscriptionId) {
|
async function getSubscription(subscriptionId) {
|
||||||
const subscription = await client.getSubscription(`uuid-${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
|
* @param {RecurlySubscriptionChangeRequest} changeRequest
|
||||||
*/
|
*/
|
||||||
async function applySubscriptionChangeRequest(changeRequest) {
|
async function applySubscriptionChangeRequest(changeRequest) {
|
||||||
/** @type {recurly.SubscriptionChangeCreate} */
|
const body = subscriptionChangeRequestToApi(changeRequest)
|
||||||
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 change = await client.createSubscriptionChange(
|
const change = await client.createSubscriptionChange(
|
||||||
`uuid-${changeRequest.subscriptionId}`,
|
`uuid-${changeRequest.subscription.id}`,
|
||||||
body
|
body
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ subscriptionId: changeRequest.subscriptionId, changeId: change.id },
|
{ subscriptionId: changeRequest.subscription.id, changeId: change.id },
|
||||||
'created subscription change'
|
'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) {
|
async function removeSubscriptionChange(subscriptionId) {
|
||||||
const removed = await client.removeSubscriptionChange(subscriptionId)
|
const removed = await client.removeSubscriptionChange(subscriptionId)
|
||||||
logger.debug({ subscriptionId }, 'removed pending subscription change')
|
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) {
|
function subscriptionIsCanceledOrExpired(subscription) {
|
||||||
const state = subscription?.recurlyStatus?.state
|
const state = subscription?.recurlyStatus?.state
|
||||||
return state === 'canceled' || state === 'expired'
|
return state === 'canceled' || state === 'expired'
|
||||||
|
@ -142,7 +172,7 @@ function subscriptionIsCanceledOrExpired(subscription) {
|
||||||
* @param {recurly.Subscription} subscription
|
* @param {recurly.Subscription} subscription
|
||||||
* @return {RecurlySubscription}
|
* @return {RecurlySubscription}
|
||||||
*/
|
*/
|
||||||
function makeSubscription(subscription) {
|
function subscriptionFromApi(subscription) {
|
||||||
if (
|
if (
|
||||||
subscription.uuid == null ||
|
subscription.uuid == null ||
|
||||||
subscription.plan == null ||
|
subscription.plan == null ||
|
||||||
|
@ -153,7 +183,9 @@ function makeSubscription(subscription) {
|
||||||
subscription.unitAmount == null ||
|
subscription.unitAmount == null ||
|
||||||
subscription.subtotal == null ||
|
subscription.subtotal == null ||
|
||||||
subscription.total == null ||
|
subscription.total == null ||
|
||||||
subscription.currency == null
|
subscription.currency == null ||
|
||||||
|
subscription.currentPeriodStartedAt == null ||
|
||||||
|
subscription.currentPeriodEndsAt == null
|
||||||
) {
|
) {
|
||||||
throw new OError('Invalid Recurly subscription', { subscription })
|
throw new OError('Invalid Recurly subscription', { subscription })
|
||||||
}
|
}
|
||||||
|
@ -163,12 +195,14 @@ function makeSubscription(subscription) {
|
||||||
planCode: subscription.plan.code,
|
planCode: subscription.plan.code,
|
||||||
planName: subscription.plan.name,
|
planName: subscription.plan.name,
|
||||||
planPrice: subscription.unitAmount,
|
planPrice: subscription.unitAmount,
|
||||||
addOns: (subscription.addOns ?? []).map(makeSubscriptionAddOn),
|
addOns: (subscription.addOns ?? []).map(subscriptionAddOnFromApi),
|
||||||
subtotal: subscription.subtotal,
|
subtotal: subscription.subtotal,
|
||||||
taxRate: subscription.taxInfo?.rate ?? 0,
|
taxRate: subscription.taxInfo?.rate ?? 0,
|
||||||
taxAmount: subscription.tax ?? 0,
|
taxAmount: subscription.tax ?? 0,
|
||||||
total: subscription.total,
|
total: subscription.total,
|
||||||
currency: subscription.currency,
|
currency: subscription.currency,
|
||||||
|
periodStart: subscription.currentPeriodStartedAt,
|
||||||
|
periodEnd: subscription.currentPeriodEndsAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -178,7 +212,7 @@ function makeSubscription(subscription) {
|
||||||
* @param {recurly.SubscriptionAddOn} addOn
|
* @param {recurly.SubscriptionAddOn} addOn
|
||||||
* @return {RecurlySubscriptionAddOn}
|
* @return {RecurlySubscriptionAddOn}
|
||||||
*/
|
*/
|
||||||
function makeSubscriptionAddOn(addOn) {
|
function subscriptionAddOnFromApi(addOn) {
|
||||||
if (
|
if (
|
||||||
addOn.addOn == null ||
|
addOn.addOn == null ||
|
||||||
addOn.addOn.code == 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 = {
|
module.exports = {
|
||||||
errors: recurly.errors,
|
errors: recurly.errors,
|
||||||
|
|
||||||
getAccountForUserId: callbackify(getAccountForUserId),
|
getAccountForUserId: callbackify(getAccountForUserId),
|
||||||
createAccountForUserId: callbackify(createAccountForUserId),
|
createAccountForUserId: callbackify(createAccountForUserId),
|
||||||
getSubscription: callbackify(getSubscription),
|
getSubscription: callbackify(getSubscription),
|
||||||
|
previewSubscriptionChange: callbackify(previewSubscriptionChange),
|
||||||
applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
|
applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
|
||||||
removeSubscriptionChange: callbackify(removeSubscriptionChange),
|
removeSubscriptionChange: callbackify(removeSubscriptionChange),
|
||||||
removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
|
removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
|
||||||
reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid),
|
reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid),
|
||||||
cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
|
cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
|
||||||
|
getPaymentMethod: callbackify(getPaymentMethod),
|
||||||
|
getAddOn: callbackify(getAddOn),
|
||||||
subscriptionIsCanceledOrExpired,
|
subscriptionIsCanceledOrExpired,
|
||||||
|
|
||||||
promises: {
|
promises: {
|
||||||
getSubscription,
|
getSubscription,
|
||||||
getAccountForUserId,
|
getAccountForUserId,
|
||||||
createAccountForUserId,
|
createAccountForUserId,
|
||||||
|
previewSubscriptionChange,
|
||||||
applySubscriptionChangeRequest,
|
applySubscriptionChangeRequest,
|
||||||
removeSubscriptionChange,
|
removeSubscriptionChange,
|
||||||
removeSubscriptionChangeByUuid,
|
removeSubscriptionChangeByUuid,
|
||||||
reactivateSubscriptionByUuid,
|
reactivateSubscriptionByUuid,
|
||||||
cancelSubscriptionByUuid,
|
cancelSubscriptionByUuid,
|
||||||
|
getPaymentMethod,
|
||||||
|
getAddOn,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,8 @@ class RecurlySubscription {
|
||||||
* @param {number} [props.taxAmount]
|
* @param {number} [props.taxAmount]
|
||||||
* @param {string} props.currency
|
* @param {string} props.currency
|
||||||
* @param {number} props.total
|
* @param {number} props.total
|
||||||
|
* @param {Date} props.periodStart
|
||||||
|
* @param {Date} props.periodEnd
|
||||||
*/
|
*/
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
this.id = props.id
|
this.id = props.id
|
||||||
|
@ -32,6 +34,8 @@ class RecurlySubscription {
|
||||||
this.taxAmount = props.taxAmount ?? 0
|
this.taxAmount = props.taxAmount ?? 0
|
||||||
this.currency = props.currency
|
this.currency = props.currency
|
||||||
this.total = props.total
|
this.total = props.total
|
||||||
|
this.periodStart = props.periodStart
|
||||||
|
this.periodEnd = props.periodEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAddOn(code) {
|
hasAddOn(code) {
|
||||||
|
@ -60,7 +64,7 @@ class RecurlySubscription {
|
||||||
)
|
)
|
||||||
const timeframe = changeAtTermEnd ? 'term_end' : 'now'
|
const timeframe = changeAtTermEnd ? 'term_end' : 'now'
|
||||||
return new RecurlySubscriptionChangeRequest({
|
return new RecurlySubscriptionChangeRequest({
|
||||||
subscriptionId: this.id,
|
subscription: this,
|
||||||
timeframe,
|
timeframe,
|
||||||
planCode,
|
planCode,
|
||||||
})
|
})
|
||||||
|
@ -87,7 +91,7 @@ class RecurlySubscription {
|
||||||
const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
|
const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
|
||||||
addOnUpdates.push(new RecurlySubscriptionAddOnUpdate({ code, quantity }))
|
addOnUpdates.push(new RecurlySubscriptionAddOnUpdate({ code, quantity }))
|
||||||
return new RecurlySubscriptionChangeRequest({
|
return new RecurlySubscriptionChangeRequest({
|
||||||
subscriptionId: this.id,
|
subscription: this,
|
||||||
timeframe: 'now',
|
timeframe: 'now',
|
||||||
addOnUpdates,
|
addOnUpdates,
|
||||||
})
|
})
|
||||||
|
@ -115,13 +119,16 @@ class RecurlySubscription {
|
||||||
.filter(addOn => addOn.code !== code)
|
.filter(addOn => addOn.code !== code)
|
||||||
.map(addOn => addOn.toAddOnUpdate())
|
.map(addOn => addOn.toAddOnUpdate())
|
||||||
return new RecurlySubscriptionChangeRequest({
|
return new RecurlySubscriptionChangeRequest({
|
||||||
subscriptionId: this.id,
|
subscription: this,
|
||||||
timeframe: 'term_end',
|
timeframe: 'term_end',
|
||||||
addOnUpdates,
|
addOnUpdates,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An add-on attached to a subscription
|
||||||
|
*/
|
||||||
class RecurlySubscriptionAddOn {
|
class RecurlySubscriptionAddOn {
|
||||||
/**
|
/**
|
||||||
* @param {object} props
|
* @param {object} props
|
||||||
|
@ -135,10 +142,7 @@ class RecurlySubscriptionAddOn {
|
||||||
this.name = props.name
|
this.name = props.name
|
||||||
this.quantity = props.quantity
|
this.quantity = props.quantity
|
||||||
this.unitPrice = props.unitPrice
|
this.unitPrice = props.unitPrice
|
||||||
}
|
this.preTaxTotal = this.quantity * this.unitPrice
|
||||||
|
|
||||||
get preTaxTotal() {
|
|
||||||
return this.quantity * this.unitPrice
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -156,7 +160,7 @@ class RecurlySubscriptionAddOn {
|
||||||
class RecurlySubscriptionChangeRequest {
|
class RecurlySubscriptionChangeRequest {
|
||||||
/**
|
/**
|
||||||
* @param {object} props
|
* @param {object} props
|
||||||
* @param {string} props.subscriptionId
|
* @param {RecurlySubscription} props.subscription
|
||||||
* @param {"now" | "term_end"} props.timeframe
|
* @param {"now" | "term_end"} props.timeframe
|
||||||
* @param {string} [props.planCode]
|
* @param {string} [props.planCode]
|
||||||
* @param {RecurlySubscriptionAddOnUpdate[]} [props.addOnUpdates]
|
* @param {RecurlySubscriptionAddOnUpdate[]} [props.addOnUpdates]
|
||||||
|
@ -165,7 +169,7 @@ class RecurlySubscriptionChangeRequest {
|
||||||
if (props.planCode == null && props.addOnUpdates == null) {
|
if (props.planCode == null && props.addOnUpdates == null) {
|
||||||
throw new OError('Invalid RecurlySubscriptionChangeRequest', { props })
|
throw new OError('Invalid RecurlySubscriptionChangeRequest', { props })
|
||||||
}
|
}
|
||||||
this.subscriptionId = props.subscriptionId
|
this.subscription = props.subscription
|
||||||
this.timeframe = props.timeframe
|
this.timeframe = props.timeframe
|
||||||
this.planCode = props.planCode ?? null
|
this.planCode = props.planCode ?? null
|
||||||
this.addOnUpdates = props.addOnUpdates ?? 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 = {
|
module.exports = {
|
||||||
RecurlySubscription,
|
RecurlySubscription,
|
||||||
RecurlySubscriptionAddOn,
|
RecurlySubscriptionAddOn,
|
||||||
|
RecurlySubscriptionChange,
|
||||||
RecurlySubscriptionChangeRequest,
|
RecurlySubscriptionChangeRequest,
|
||||||
RecurlySubscriptionAddOnUpdate,
|
RecurlySubscriptionAddOnUpdate,
|
||||||
|
PaypalPaymentMethod,
|
||||||
|
CreditCardPaymentMethod,
|
||||||
|
RecurlyAddOn,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
const SessionManager = require('../Authentication/SessionManager')
|
const SessionManager = require('../Authentication/SessionManager')
|
||||||
const SubscriptionHandler = require('./SubscriptionHandler')
|
const SubscriptionHandler = require('./SubscriptionHandler')
|
||||||
const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
|
const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
|
||||||
|
@ -25,8 +27,13 @@ const { formatCurrencyLocalized } = require('../../util/currency')
|
||||||
const SubscriptionFormatters = require('./SubscriptionFormatters')
|
const SubscriptionFormatters = require('./SubscriptionFormatters')
|
||||||
const HttpErrorHandler = require('../Errors/HttpErrorHandler')
|
const HttpErrorHandler = require('../Errors/HttpErrorHandler')
|
||||||
const { URLSearchParams } = require('url')
|
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 groupPlanModalOptions = Settings.groupPlanModalOptions
|
||||||
const validGroupPlanModalOptions = {
|
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) {
|
async function userSubscriptionPage(req, res) {
|
||||||
const user = SessionManager.getSessionUser(req.session)
|
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) {
|
async function successfulSubscription(req, res) {
|
||||||
const user = SessionManager.getSessionUser(req.session)
|
const user = SessionManager.getSessionUser(req.session)
|
||||||
const localCcyAssignment = await SplitTestHandler.promises.getAssignment(
|
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) {
|
async function purchaseAddon(req, res, next) {
|
||||||
const user = SessionManager.getSessionUser(req.session)
|
const user = SessionManager.getSessionUser(req.session)
|
||||||
const addOnCode = req.params.addOnCode
|
const addOnCode = req.params.addOnCode
|
||||||
// currently we only support having a quantity of 1
|
// currently we only support having a quantity of 1
|
||||||
const quantity = 1
|
const quantity = 1
|
||||||
// currently we only support one add-on, the Ai add-on
|
// 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)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -512,10 +577,12 @@ async function purchaseAddon(req, res, next) {
|
||||||
{ addon: addOnCode }
|
{ addon: addOnCode }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
OError.tag(err, 'something went wrong purchasing add-ons', {
|
if (err instanceof Error) {
|
||||||
user_id: user._id,
|
OError.tag(err, 'something went wrong purchasing add-ons', {
|
||||||
addOnCode,
|
user_id: user._id,
|
||||||
})
|
addOnCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
return next(err)
|
return next(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -525,7 +592,7 @@ async function removeAddon(req, res, next) {
|
||||||
const user = SessionManager.getSessionUser(req.session)
|
const user = SessionManager.getSessionUser(req.session)
|
||||||
const addOnCode = req.params.addOnCode
|
const addOnCode = req.params.addOnCode
|
||||||
|
|
||||||
if (addOnCode !== AI_ADDON_CODE) {
|
if (!AI_ADDON_CODES.includes(addOnCode)) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -543,10 +610,12 @@ async function removeAddon(req, res, next) {
|
||||||
{ addon: addOnCode }
|
{ addon: addOnCode }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
OError.tag(err, 'something went wrong removing add-ons', {
|
if (err instanceof Error) {
|
||||||
user_id: user._id,
|
OError.tag(err, 'something went wrong removing add-ons', {
|
||||||
addOnCode,
|
user_id: user._id,
|
||||||
})
|
addOnCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
return next(err)
|
return next(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -839,6 +908,7 @@ module.exports = {
|
||||||
refreshUserFeatures: expressify(refreshUserFeatures),
|
refreshUserFeatures: expressify(refreshUserFeatures),
|
||||||
redirectToHostedPage: expressify(redirectToHostedPage),
|
redirectToHostedPage: expressify(redirectToHostedPage),
|
||||||
plansBanners: _plansBanners,
|
plansBanners: _plansBanners,
|
||||||
|
previewAddonPurchase: expressify(previewAddonPurchase),
|
||||||
purchaseAddon,
|
purchaseAddon,
|
||||||
removeAddon,
|
removeAddon,
|
||||||
promises: {
|
promises: {
|
||||||
|
|
|
@ -2,7 +2,7 @@ const dateformat = require('dateformat')
|
||||||
const { formatCurrencyLocalized } = require('../../util/currency')
|
const { formatCurrencyLocalized } = require('../../util/currency')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @import { CurrencyCode } from '@/shared/utils/currency'
|
* @import { CurrencyCode } from '../../../../types/currency-code'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const currencySymbols = {
|
const currencySymbols = {
|
||||||
|
|
|
@ -5,6 +5,7 @@ const RecurlyClient = require('./RecurlyClient')
|
||||||
const { User } = require('../../models/User')
|
const { User } = require('../../models/User')
|
||||||
const logger = require('@overleaf/logger')
|
const logger = require('@overleaf/logger')
|
||||||
const SubscriptionUpdater = require('./SubscriptionUpdater')
|
const SubscriptionUpdater = require('./SubscriptionUpdater')
|
||||||
|
const SubscriptionLocator = require('./SubscriptionLocator')
|
||||||
const LimitationsManager = require('./LimitationsManager')
|
const LimitationsManager = require('./LimitationsManager')
|
||||||
const EmailHandler = require('../Email/EmailHandler')
|
const EmailHandler = require('../Email/EmailHandler')
|
||||||
const { callbackify } = require('@overleaf/promise-utils')
|
const { callbackify } = require('@overleaf/promise-utils')
|
||||||
|
@ -12,7 +13,8 @@ const UserUpdater = require('../User/UserUpdater')
|
||||||
const { NoRecurlySubscriptionError } = require('./Errors')
|
const { NoRecurlySubscriptionError } = require('./Errors')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @import { RecurlySubscription } from './RecurlyEntities'
|
* @import recurly from 'recurly'
|
||||||
|
* @import { RecurlySubscription, RecurlySubscriptionChange } from './RecurlyEntities'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async function validateNoSubscriptionInRecurly(userId) {
|
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) {
|
async function updateSubscription(user, planCode, couponCode) {
|
||||||
let hasSubscription = false
|
let hasSubscription = false
|
||||||
let subscription
|
let subscription
|
||||||
|
@ -105,6 +112,9 @@ async function updateSubscription(user, planCode, couponCode) {
|
||||||
await syncSubscription({ uuid: recurlySubscriptionId }, user._id)
|
await syncSubscription({ uuid: recurlySubscriptionId }, user._id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param user
|
||||||
|
*/
|
||||||
async function cancelPendingSubscriptionChange(user) {
|
async function cancelPendingSubscriptionChange(user) {
|
||||||
const { hasSubscription, subscription } =
|
const { hasSubscription, subscription } =
|
||||||
await LimitationsManager.promises.userHasV2Subscription(user)
|
await LimitationsManager.promises.userHasV2Subscription(user)
|
||||||
|
@ -116,6 +126,9 @@ async function cancelPendingSubscriptionChange(user) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param user
|
||||||
|
*/
|
||||||
async function cancelSubscription(user) {
|
async function cancelSubscription(user) {
|
||||||
try {
|
try {
|
||||||
const { hasSubscription, subscription } =
|
const { hasSubscription, subscription } =
|
||||||
|
@ -144,6 +157,9 @@ async function cancelSubscription(user) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param user
|
||||||
|
*/
|
||||||
async function reactivateSubscription(user) {
|
async function reactivateSubscription(user) {
|
||||||
try {
|
try {
|
||||||
const { hasSubscription, subscription } =
|
const { hasSubscription, subscription } =
|
||||||
|
@ -174,6 +190,10 @@ async function reactivateSubscription(user) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param recurlySubscription
|
||||||
|
* @param requesterData
|
||||||
|
*/
|
||||||
async function syncSubscription(recurlySubscription, requesterData) {
|
async function syncSubscription(recurlySubscription, requesterData) {
|
||||||
const storedSubscription = await RecurlyWrapper.promises.getSubscription(
|
const storedSubscription = await RecurlyWrapper.promises.getSubscription(
|
||||||
recurlySubscription.uuid,
|
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.
|
* attempt to collect past due invoice for customer. Only do that when a) the
|
||||||
// This is used because Recurly doesn't always attempt collection of paast due
|
* customer is using Paypal and b) there is only one past due invoice.
|
||||||
// invoices after Paypal billing info were updated.
|
* 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) {
|
async function attemptPaypalInvoiceCollection(recurlyAccountCode) {
|
||||||
const billingInfo =
|
const billingInfo =
|
||||||
await RecurlyWrapper.promises.getBillingInfo(recurlyAccountCode)
|
await RecurlyWrapper.promises.getBillingInfo(recurlyAccountCode)
|
||||||
|
@ -259,6 +283,26 @@ async function _getSubscription(user) {
|
||||||
return currentSub
|
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) {
|
async function purchaseAddon(user, addOnCode, quantity) {
|
||||||
const subscription = await _getSubscription(user)
|
const subscription = await _getSubscription(user)
|
||||||
const changeRequest = subscription.getRequestForAddOnPurchase(
|
const changeRequest = subscription.getRequestForAddOnPurchase(
|
||||||
|
@ -276,6 +320,21 @@ async function removeAddon(user, addOnCode) {
|
||||||
await syncSubscription({ uuid: subscription.id }, user._id)
|
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 = {
|
module.exports = {
|
||||||
validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
|
validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
|
||||||
createSubscription: callbackify(createSubscription),
|
createSubscription: callbackify(createSubscription),
|
||||||
|
@ -286,6 +345,7 @@ module.exports = {
|
||||||
syncSubscription: callbackify(syncSubscription),
|
syncSubscription: callbackify(syncSubscription),
|
||||||
attemptPaypalInvoiceCollection: callbackify(attemptPaypalInvoiceCollection),
|
attemptPaypalInvoiceCollection: callbackify(attemptPaypalInvoiceCollection),
|
||||||
extendTrial: callbackify(extendTrial),
|
extendTrial: callbackify(extendTrial),
|
||||||
|
previewAddonPurchase: callbackify(previewAddonPurchase),
|
||||||
purchaseAddon: callbackify(purchaseAddon),
|
purchaseAddon: callbackify(purchaseAddon),
|
||||||
removeAddon: callbackify(removeAddon),
|
removeAddon: callbackify(removeAddon),
|
||||||
promises: {
|
promises: {
|
||||||
|
@ -298,6 +358,7 @@ module.exports = {
|
||||||
syncSubscription,
|
syncSubscription,
|
||||||
attemptPaypalInvoiceCollection,
|
attemptPaypalInvoiceCollection,
|
||||||
extendTrial,
|
extendTrial,
|
||||||
|
previewAddonPurchase,
|
||||||
purchaseAddon,
|
purchaseAddon,
|
||||||
removeAddon,
|
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),
|
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
|
||||||
SubscriptionController.updateSubscription
|
SubscriptionController.updateSubscription
|
||||||
)
|
)
|
||||||
|
webRouter.get(
|
||||||
|
'/user/subscription/addon/:addOnCode/add',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
|
||||||
|
SubscriptionController.previewAddonPurchase
|
||||||
|
)
|
||||||
webRouter.post(
|
webRouter.post(
|
||||||
'/user/subscription/addon/:addOnCode/add',
|
'/user/subscription/addon/:addOnCode/add',
|
||||||
AuthenticationController.requireLogin(),
|
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": "",
|
"active": "",
|
||||||
"add": "",
|
"add": "",
|
||||||
"add_a_recovery_email_address": "",
|
"add_a_recovery_email_address": "",
|
||||||
|
"add_add_on_to_your_plan": "",
|
||||||
"add_additional_certificate": "",
|
"add_additional_certificate": "",
|
||||||
"add_affiliation": "",
|
"add_affiliation": "",
|
||||||
"add_another_address_line": "",
|
"add_another_address_line": "",
|
||||||
|
@ -522,6 +523,7 @@
|
||||||
"full_doc_history": "",
|
"full_doc_history": "",
|
||||||
"full_project_search": "",
|
"full_project_search": "",
|
||||||
"full_width": "",
|
"full_width": "",
|
||||||
|
"future_payments": "",
|
||||||
"generate_token": "",
|
"generate_token": "",
|
||||||
"generic_if_problem_continues_contact_us": "",
|
"generic_if_problem_continues_contact_us": "",
|
||||||
"generic_linked_file_compile_error": "",
|
"generic_linked_file_compile_error": "",
|
||||||
|
@ -1027,6 +1029,7 @@
|
||||||
"paste_options": "",
|
"paste_options": "",
|
||||||
"paste_with_formatting": "",
|
"paste_with_formatting": "",
|
||||||
"paste_without_formatting": "",
|
"paste_without_formatting": "",
|
||||||
|
"pay_now": "",
|
||||||
"payment_provider_unreachable_error": "",
|
"payment_provider_unreachable_error": "",
|
||||||
"payment_summary": "",
|
"payment_summary": "",
|
||||||
"pdf_compile_in_progress_error": "",
|
"pdf_compile_in_progress_error": "",
|
||||||
|
@ -1522,7 +1525,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_folder_already_exists_in_this_project_plural": "",
|
||||||
|
"the_next_payment_will_be_collected_on": "",
|
||||||
"the_original_text_has_changed": "",
|
"the_original_text_has_changed": "",
|
||||||
|
"the_payment_method_used_is": "",
|
||||||
"the_target_folder_could_not_be_found": "",
|
"the_target_folder_could_not_be_found": "",
|
||||||
"the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "",
|
"the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "",
|
||||||
"their_projects_will_be_transferred_to_another_user": "",
|
"their_projects_will_be_transferred_to_another_user": "",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import getMeta from '../../../utils/meta'
|
import getMeta from '../../../utils/meta'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @import { CurrencyCode } from '@/shared/utils/currency'
|
* @import { CurrencyCode } from '../../../../../types/currency-code'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// plan: 'collaborator' or 'professional'
|
// 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 =
|
import getMeta from '@/utils/meta'
|
||||||
| 'AUD'
|
|
||||||
| 'BRL'
|
const DEFAULT_LOCALE = getMeta('ol-i18n')?.currentLangCode ?? 'en'
|
||||||
| 'CAD'
|
|
||||||
| 'CHF'
|
|
||||||
| 'CLP'
|
|
||||||
| 'COP'
|
|
||||||
| 'DKK'
|
|
||||||
| 'EUR'
|
|
||||||
| 'GBP'
|
|
||||||
| 'INR'
|
|
||||||
| 'MXN'
|
|
||||||
| 'NOK'
|
|
||||||
| 'NZD'
|
|
||||||
| 'PEN'
|
|
||||||
| 'SEK'
|
|
||||||
| 'SGD'
|
|
||||||
| 'USD'
|
|
||||||
|
|
||||||
export function formatCurrencyLocalized(
|
export function formatCurrencyLocalized(
|
||||||
amount: number,
|
amount: number,
|
||||||
currency: CurrencyCode,
|
currency: string,
|
||||||
locale: string,
|
locale: string = DEFAULT_LOCALE,
|
||||||
stripIfInteger = false
|
stripIfInteger = false
|
||||||
): string {
|
): string {
|
||||||
const options: Intl.NumberFormatOptions = { style: 'currency', currency }
|
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 { Subscription as ProjectDashboardSubscription } from '../../../types/project/dashboard/subscription'
|
||||||
import { ThirdPartyIds } from '../../../types/third-party-ids'
|
import { ThirdPartyIds } from '../../../types/third-party-ids'
|
||||||
import { Publisher } from '../../../types/subscription/dashboard/publisher'
|
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 { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
|
||||||
import { FatFooterMetadata } from '@/features/ui/components/types/fat-footer-metadata'
|
import { FatFooterMetadata } from '@/features/ui/components/types/fat-footer-metadata'
|
||||||
export interface Meta {
|
export interface Meta {
|
||||||
|
@ -191,6 +192,7 @@ export interface Meta {
|
||||||
'ol-ssoDisabled': boolean
|
'ol-ssoDisabled': boolean
|
||||||
'ol-ssoErrorMessage': string
|
'ol-ssoErrorMessage': string
|
||||||
'ol-subscription': any // TODO: mixed types, split into two fields
|
'ol-subscription': any // TODO: mixed types, split into two fields
|
||||||
|
'ol-subscriptionChangePreview': SubscriptionChangePreview
|
||||||
'ol-subscriptionId': string
|
'ol-subscriptionId': string
|
||||||
'ol-suggestedLanguage': SuggestedLanguage | undefined
|
'ol-suggestedLanguage': SuggestedLanguage | undefined
|
||||||
'ol-survey': Survey | undefined
|
'ol-survey': Survey | undefined
|
||||||
|
|
|
@ -286,3 +286,16 @@ a.row-link {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
width: 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",
|
"active": "Active",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"add_a_recovery_email_address": "Add a recovery email address",
|
"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_additional_certificate": "Add another certificate",
|
||||||
"add_affiliation": "Add Affiliation",
|
"add_affiliation": "Add Affiliation",
|
||||||
"add_another_address_line": "Add another address line",
|
"add_another_address_line": "Add another address line",
|
||||||
|
@ -748,6 +749,7 @@
|
||||||
"full_document_history": "Full document <0>history</0>",
|
"full_document_history": "Full document <0>history</0>",
|
||||||
"full_project_search": "Full Project Search",
|
"full_project_search": "Full Project Search",
|
||||||
"full_width": "Full width",
|
"full_width": "Full width",
|
||||||
|
"future_payments": "Future payments",
|
||||||
"gallery": "Gallery",
|
"gallery": "Gallery",
|
||||||
"gallery_back_to_all": "Back to all __itemPlural__",
|
"gallery_back_to_all": "Back to all __itemPlural__",
|
||||||
"gallery_find_more": "Find More __itemPlural__",
|
"gallery_find_more": "Find More __itemPlural__",
|
||||||
|
@ -1483,6 +1485,7 @@
|
||||||
"paste_options": "Paste options",
|
"paste_options": "Paste options",
|
||||||
"paste_with_formatting": "Paste with formatting",
|
"paste_with_formatting": "Paste with formatting",
|
||||||
"paste_without_formatting": "Paste without formatting",
|
"paste_without_formatting": "Paste without formatting",
|
||||||
|
"pay_now": "Pay now",
|
||||||
"payment_method_accepted": "__paymentMethod__ accepted",
|
"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_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",
|
"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_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": "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_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_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_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_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.",
|
"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',
|
name: 'My Add-On',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitPrice: 2,
|
unitPrice: 2,
|
||||||
|
preTaxTotal: 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.subscription = {
|
this.subscription = {
|
||||||
|
@ -46,6 +47,8 @@ describe('RecurlyClient', function () {
|
||||||
taxRate: 0.1,
|
taxRate: 0.1,
|
||||||
taxAmount: 1.5,
|
taxAmount: 1.5,
|
||||||
total: 16.5,
|
total: 16.5,
|
||||||
|
periodStart: new Date(),
|
||||||
|
periodEnd: new Date(),
|
||||||
}
|
}
|
||||||
|
|
||||||
this.recurlySubscription = {
|
this.recurlySubscription = {
|
||||||
|
@ -73,6 +76,8 @@ describe('RecurlyClient', function () {
|
||||||
tax: this.subscription.taxAmount,
|
tax: this.subscription.taxAmount,
|
||||||
total: this.subscription.total,
|
total: this.subscription.total,
|
||||||
currency: this.subscription.currency,
|
currency: this.subscription.currency,
|
||||||
|
currentPeriodStartedAt: this.subscription.periodStart,
|
||||||
|
currentPeriodEndsAt: this.subscription.periodEnd,
|
||||||
}
|
}
|
||||||
|
|
||||||
this.recurlySubscriptionChange = new recurly.SubscriptionChange()
|
this.recurlySubscriptionChange = new recurly.SubscriptionChange()
|
||||||
|
@ -203,7 +208,7 @@ describe('RecurlyClient', function () {
|
||||||
it('handles plan changes', async function () {
|
it('handles plan changes', async function () {
|
||||||
await this.RecurlyClient.promises.applySubscriptionChangeRequest(
|
await this.RecurlyClient.promises.applySubscriptionChangeRequest(
|
||||||
new RecurlySubscriptionChangeRequest({
|
new RecurlySubscriptionChangeRequest({
|
||||||
subscriptionId: this.subscription.id,
|
subscription: this.subscription,
|
||||||
timeframe: 'now',
|
timeframe: 'now',
|
||||||
planCode: 'new-plan',
|
planCode: 'new-plan',
|
||||||
})
|
})
|
||||||
|
@ -217,7 +222,7 @@ describe('RecurlyClient', function () {
|
||||||
it('handles add-on changes', async function () {
|
it('handles add-on changes', async function () {
|
||||||
await this.RecurlyClient.promises.applySubscriptionChangeRequest(
|
await this.RecurlyClient.promises.applySubscriptionChangeRequest(
|
||||||
new RecurlySubscriptionChangeRequest({
|
new RecurlySubscriptionChangeRequest({
|
||||||
subscriptionId: this.subscription.id,
|
subscription: this.subscription,
|
||||||
timeframe: 'now',
|
timeframe: 'now',
|
||||||
addOnUpdates: [
|
addOnUpdates: [
|
||||||
new RecurlySubscriptionAddOnUpdate({
|
new RecurlySubscriptionAddOnUpdate({
|
||||||
|
@ -240,10 +245,9 @@ describe('RecurlyClient', function () {
|
||||||
it('should throw any API errors', async function () {
|
it('should throw any API errors', async function () {
|
||||||
this.client.createSubscriptionChange = sinon.stub().throws()
|
this.client.createSubscriptionChange = sinon.stub().throws()
|
||||||
await expect(
|
await expect(
|
||||||
this.RecurlyClient.promises.applySubscriptionChangeRequest(
|
this.RecurlyClient.promises.applySubscriptionChangeRequest({
|
||||||
this.subscription.id,
|
subscription: this.subscription,
|
||||||
{}
|
})
|
||||||
)
|
|
||||||
).to.eventually.be.rejectedWith(Error)
|
).to.eventually.be.rejectedWith(Error)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -5,6 +5,8 @@ const { expect } = require('chai')
|
||||||
const Errors = require('../../../../app/src/Features/Subscription/Errors')
|
const Errors = require('../../../../app/src/Features/Subscription/Errors')
|
||||||
const {
|
const {
|
||||||
RecurlySubscriptionChangeRequest,
|
RecurlySubscriptionChangeRequest,
|
||||||
|
RecurlySubscriptionChange,
|
||||||
|
RecurlySubscription,
|
||||||
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
|
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
|
||||||
|
|
||||||
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
|
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
|
||||||
|
@ -71,7 +73,7 @@ describe('RecurlyEntities', function () {
|
||||||
this.subscription.getRequestForPlanChange('premium-plan')
|
this.subscription.getRequestForPlanChange('premium-plan')
|
||||||
expect(changeRequest).to.deep.equal(
|
expect(changeRequest).to.deep.equal(
|
||||||
new RecurlySubscriptionChangeRequest({
|
new RecurlySubscriptionChangeRequest({
|
||||||
subscriptionId: this.subscription.id,
|
subscription: this.subscription,
|
||||||
timeframe: 'now',
|
timeframe: 'now',
|
||||||
planCode: 'premium-plan',
|
planCode: 'premium-plan',
|
||||||
})
|
})
|
||||||
|
@ -84,7 +86,7 @@ describe('RecurlyEntities', function () {
|
||||||
this.subscription.getRequestForPlanChange('cheap-plan')
|
this.subscription.getRequestForPlanChange('cheap-plan')
|
||||||
expect(changeRequest).to.deep.equal(
|
expect(changeRequest).to.deep.equal(
|
||||||
new RecurlySubscriptionChangeRequest({
|
new RecurlySubscriptionChangeRequest({
|
||||||
subscriptionId: this.subscription.id,
|
subscription: this.subscription,
|
||||||
timeframe: 'term_end',
|
timeframe: 'term_end',
|
||||||
planCode: 'cheap-plan',
|
planCode: 'cheap-plan',
|
||||||
})
|
})
|
||||||
|
@ -102,7 +104,7 @@ describe('RecurlyEntities', function () {
|
||||||
this.subscription.getRequestForAddOnPurchase('another-add-on')
|
this.subscription.getRequestForAddOnPurchase('another-add-on')
|
||||||
expect(changeRequest).to.deep.equal(
|
expect(changeRequest).to.deep.equal(
|
||||||
new RecurlySubscriptionChangeRequest({
|
new RecurlySubscriptionChangeRequest({
|
||||||
subscriptionId: this.subscription.id,
|
subscription: this.subscription,
|
||||||
timeframe: 'now',
|
timeframe: 'now',
|
||||||
addOnUpdates: [
|
addOnUpdates: [
|
||||||
new RecurlySubscriptionAddOnUpdate({
|
new RecurlySubscriptionAddOnUpdate({
|
||||||
|
@ -133,7 +135,7 @@ describe('RecurlyEntities', function () {
|
||||||
)
|
)
|
||||||
expect(changeRequest).to.deep.equal(
|
expect(changeRequest).to.deep.equal(
|
||||||
new RecurlySubscriptionChangeRequest({
|
new RecurlySubscriptionChangeRequest({
|
||||||
subscriptionId: this.subscription.id,
|
subscription: this.subscription,
|
||||||
timeframe: 'term_end',
|
timeframe: 'term_end',
|
||||||
addOnUpdates: [],
|
addOnUpdates: [],
|
||||||
})
|
})
|
||||||
|
@ -180,7 +182,7 @@ describe('RecurlyEntities', function () {
|
||||||
this.subscription.getRequestForAddOnPurchase('some-add-on')
|
this.subscription.getRequestForAddOnPurchase('some-add-on')
|
||||||
expect(changeRequest).to.deep.equal(
|
expect(changeRequest).to.deep.equal(
|
||||||
new RecurlySubscriptionChangeRequest({
|
new RecurlySubscriptionChangeRequest({
|
||||||
subscriptionId: this.subscription.id,
|
subscription: this.subscription,
|
||||||
timeframe: 'now',
|
timeframe: 'now',
|
||||||
addOnUpdates: [
|
addOnUpdates: [
|
||||||
new RecurlySubscriptionAddOnUpdate({
|
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 = {
|
this.EmailHandler = {
|
||||||
sendEmail: sinon.stub(),
|
sendEmail: sinon.stub(),
|
||||||
sendDeferredEmail: sinon.stub(),
|
sendDeferredEmail: sinon.stub(),
|
||||||
|
@ -142,6 +148,7 @@ describe('SubscriptionHandler', function () {
|
||||||
User: this.User,
|
User: this.User,
|
||||||
},
|
},
|
||||||
'./SubscriptionUpdater': this.SubscriptionUpdater,
|
'./SubscriptionUpdater': this.SubscriptionUpdater,
|
||||||
|
'./SubscriptionLocator': this.SubscriptionLocator,
|
||||||
'./LimitationsManager': this.LimitationsManager,
|
'./LimitationsManager': this.LimitationsManager,
|
||||||
'../Email/EmailHandler': this.EmailHandler,
|
'../Email/EmailHandler': this.EmailHandler,
|
||||||
'../Analytics/AnalyticsManager': this.AnalyticsManager,
|
'../Analytics/AnalyticsManager': this.AnalyticsManager,
|
||||||
|
@ -267,7 +274,7 @@ describe('SubscriptionHandler', function () {
|
||||||
this.RecurlyClient.promises.applySubscriptionChangeRequest
|
this.RecurlyClient.promises.applySubscriptionChangeRequest
|
||||||
).to.have.been.calledWith(
|
).to.have.been.calledWith(
|
||||||
new RecurlySubscriptionChangeRequest({
|
new RecurlySubscriptionChangeRequest({
|
||||||
subscriptionId: this.subscription.recurlySubscription_id,
|
subscription: this.activeRecurlyClientSubscription,
|
||||||
timeframe: 'now',
|
timeframe: 'now',
|
||||||
planCode: this.plan_code,
|
planCode: this.plan_code,
|
||||||
})
|
})
|
||||||
|
@ -380,7 +387,7 @@ describe('SubscriptionHandler', function () {
|
||||||
this.RecurlyClient.promises.applySubscriptionChangeRequest
|
this.RecurlyClient.promises.applySubscriptionChangeRequest
|
||||||
).to.be.calledWith(
|
).to.be.calledWith(
|
||||||
new RecurlySubscriptionChangeRequest({
|
new RecurlySubscriptionChangeRequest({
|
||||||
subscriptionId: this.subscription.recurlySubscription_id,
|
subscription: this.activeRecurlyClientSubscription,
|
||||||
timeframe: 'now',
|
timeframe: 'now',
|
||||||
planCode: this.plan_code,
|
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