Merge pull request #24519 from overleaf/kh-add-payment-service-low-delta
[web] add `PaymentService` to `buildUsersSubscriptionViewModel` GitOrigin-RevId: 543531226bad38d34b225dae28cf00a5e02e5558
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// @ts-check
|
||||
|
||||
const RecurlyClient = require('./RecurlyClient.js')
|
||||
const logger = require('@overleaf/logger')
|
||||
const { callbackify } = require('util')
|
||||
|
||||
/**
|
||||
* @import { RecurlySubscription, RecurlyAccount, RecurlyCoupon } from "./RecurlyEntities"
|
||||
* @import { ObjectId } from 'mongodb'
|
||||
*/
|
||||
|
||||
/**
|
||||
* this represents a subset of the Mongo Subscription record
|
||||
*
|
||||
* @typedef {object} MongoSubscription
|
||||
* @property {ObjectId} admin_id
|
||||
* @property {string} [recurlySubscription_id]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} PaymentRecord
|
||||
* @property {RecurlySubscription} subscription
|
||||
* @property {RecurlyAccount | null} account
|
||||
* @property {RecurlyCoupon[]} coupons
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get payment information from our Mongo record
|
||||
*
|
||||
* @param {MongoSubscription} subscription
|
||||
* @return {Promise<PaymentRecord | null>}
|
||||
*/
|
||||
async function getPaymentFromRecord(subscription) {
|
||||
if (subscription == null) {
|
||||
logger.debug('no subscription provided')
|
||||
return null
|
||||
}
|
||||
const userId = (subscription.admin_id._id || subscription.admin_id).toString()
|
||||
const recurlySubscriptionId = subscription.recurlySubscription_id
|
||||
|
||||
// TODO: handle non-recurly payment records
|
||||
if (recurlySubscriptionId == null || recurlySubscriptionId === '') {
|
||||
logger.debug(
|
||||
{ userId },
|
||||
"no recurly subscription id found for user's subscription"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const subscriptionResponse = await RecurlyClient.promises.getSubscription(
|
||||
recurlySubscriptionId
|
||||
)
|
||||
if (!subscriptionResponse) {
|
||||
logger.debug(
|
||||
{ recurlySubscriptionId },
|
||||
'no recurly subscription found for subscription id'
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const accountResponse =
|
||||
await RecurlyClient.promises.getAccountForUserId(userId)
|
||||
const accountCoupons =
|
||||
await RecurlyClient.promises.getActiveCouponsForUserId(userId)
|
||||
|
||||
// TODO: include account and coupons in subscription class instead of separately here
|
||||
// if Recurly is removed (Recurly needs 2 extra requests to get account & coupon data)
|
||||
return {
|
||||
subscription: subscriptionResponse,
|
||||
account: accountResponse,
|
||||
coupons: accountCoupons,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPaymentFromRecord: callbackify(getPaymentFromRecord),
|
||||
|
||||
promises: {
|
||||
getPaymentFromRecord,
|
||||
},
|
||||
}
|
||||
@@ -14,6 +14,8 @@ const {
|
||||
CreditCardPaymentMethod,
|
||||
RecurlyAddOn,
|
||||
RecurlyPlan,
|
||||
RecurlyCoupon,
|
||||
RecurlyAccount,
|
||||
RecurlyImmediateCharge,
|
||||
} = require('./RecurlyEntities')
|
||||
const {
|
||||
@@ -31,13 +33,21 @@ const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined
|
||||
|
||||
const client = new recurly.Client(recurlyApiKey)
|
||||
|
||||
/**
|
||||
* Get account for a given user
|
||||
*
|
||||
* @param {string} userId
|
||||
* @return {Promise<RecurlyAccount | null>}
|
||||
*/
|
||||
async function getAccountForUserId(userId) {
|
||||
try {
|
||||
return await client.getAccount(`code-${userId}`)
|
||||
const account = await client.getAccount(`code-${userId}`)
|
||||
return accountFromApi(account)
|
||||
} catch (err) {
|
||||
if (err instanceof recurly.errors.NotFoundError) {
|
||||
// An expected error, we don't need to handle it, just return nothing
|
||||
logger.debug({ userId }, 'no recurly account found for user')
|
||||
return null
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
@@ -62,6 +72,34 @@ async function createAccountForUserId(userId) {
|
||||
return account
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active coupons for a given user
|
||||
*
|
||||
* @param {string} userId
|
||||
* @return {Promise<RecurlyCoupon[]>}
|
||||
*/
|
||||
async function getActiveCouponsForUserId(userId) {
|
||||
try {
|
||||
const redemptions = await client.listActiveCouponRedemptions(
|
||||
`code-${userId}`
|
||||
)
|
||||
|
||||
const coupons = []
|
||||
for await (const redemption of redemptions.each()) {
|
||||
coupons.push(couponFromApi(redemption))
|
||||
}
|
||||
|
||||
return coupons
|
||||
} catch (err) {
|
||||
// An expected error if no coupons have been redeemed
|
||||
if (err instanceof recurly.errors.NotFoundError) {
|
||||
return []
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a subscription from Recurly
|
||||
*
|
||||
@@ -291,6 +329,44 @@ function subscriptionIsCanceledOrExpired(subscription) {
|
||||
return state === 'canceled' || state === 'expired'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a RecurlyAccount from Recurly API data
|
||||
*
|
||||
* @param {recurly.Account} apiAccount
|
||||
* @return {RecurlyAccount}
|
||||
*/
|
||||
function accountFromApi(apiAccount) {
|
||||
if (apiAccount.code == null || apiAccount.email == null) {
|
||||
throw new OError('Invalid Recurly account', {
|
||||
account: apiAccount,
|
||||
})
|
||||
}
|
||||
return new RecurlyAccount({
|
||||
code: apiAccount.code,
|
||||
email: apiAccount.email,
|
||||
hasPastDueInvoice: apiAccount.hasPastDueInvoice ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a RecurlyCoupon from Recurly API data
|
||||
*
|
||||
* @param {recurly.CouponRedemption} apiRedemption
|
||||
* @return {RecurlyCoupon}
|
||||
*/
|
||||
function couponFromApi(apiRedemption) {
|
||||
if (apiRedemption.coupon == null || apiRedemption.coupon.code == null) {
|
||||
throw new OError('Invalid Recurly coupon', {
|
||||
coupon: apiRedemption,
|
||||
})
|
||||
}
|
||||
return new RecurlyCoupon({
|
||||
code: apiRedemption.coupon.code,
|
||||
name: apiRedemption.coupon.name ?? '',
|
||||
description: apiRedemption.coupon.hostedPageDescription ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a RecurlySubscription from Recurly API data
|
||||
*
|
||||
@@ -333,6 +409,11 @@ function subscriptionFromApi(apiSubscription) {
|
||||
periodStart: apiSubscription.currentPeriodStartedAt,
|
||||
periodEnd: apiSubscription.currentPeriodEndsAt,
|
||||
collectionMethod: apiSubscription.collectionMethod,
|
||||
service: 'recurly',
|
||||
state: apiSubscription.state ?? 'active',
|
||||
trialPeriodEnd: apiSubscription.trialEndsAt,
|
||||
pausePeriodStart: apiSubscription.pausedAt,
|
||||
remainingPauseCycles: apiSubscription.remainingPauseCycles,
|
||||
})
|
||||
|
||||
if (apiSubscription.pendingChange != null) {
|
||||
@@ -525,6 +606,7 @@ module.exports = {
|
||||
|
||||
getAccountForUserId: callbackify(getAccountForUserId),
|
||||
createAccountForUserId: callbackify(createAccountForUserId),
|
||||
getActiveCouponsForUserId: callbackify(getActiveCouponsForUserId),
|
||||
getSubscription: callbackify(getSubscription),
|
||||
getSubscriptionForUser: callbackify(getSubscriptionForUser),
|
||||
previewSubscriptionChange: callbackify(previewSubscriptionChange),
|
||||
@@ -545,6 +627,7 @@ module.exports = {
|
||||
getSubscriptionForUser,
|
||||
getAccountForUserId,
|
||||
createAccountForUserId,
|
||||
getActiveCouponsForUserId,
|
||||
previewSubscriptionChange,
|
||||
applySubscriptionChangeRequest,
|
||||
removeSubscriptionChange,
|
||||
|
||||
@@ -27,6 +27,11 @@ class RecurlySubscription {
|
||||
* @param {Date} props.periodEnd
|
||||
* @param {string} props.collectionMethod
|
||||
* @param {RecurlySubscriptionChange} [props.pendingChange]
|
||||
* @param {string} [props.service]
|
||||
* @param {string} [props.state]
|
||||
* @param {Date|null} [props.trialPeriodEnd]
|
||||
* @param {Date|null} [props.pausePeriodStart]
|
||||
* @param {number|null} [props.remainingPauseCycles]
|
||||
*/
|
||||
constructor(props) {
|
||||
this.id = props.id
|
||||
@@ -44,6 +49,11 @@ class RecurlySubscription {
|
||||
this.periodEnd = props.periodEnd
|
||||
this.collectionMethod = props.collectionMethod
|
||||
this.pendingChange = props.pendingChange ?? null
|
||||
this.service = props.service ?? 'recurly'
|
||||
this.state = props.state ?? 'active'
|
||||
this.trialPeriodEnd = props.trialPeriodEnd ?? null
|
||||
this.pausePeriodStart = props.pausePeriodStart ?? null
|
||||
this.remainingPauseCycles = props.remainingPauseCycles ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -424,6 +434,40 @@ class RecurlyPlan {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A coupon in the payment provider
|
||||
*/
|
||||
class RecurlyCoupon {
|
||||
/**
|
||||
* @param {object} props
|
||||
* @param {string} props.code
|
||||
* @param {string} props.name
|
||||
* @param {string} props.description
|
||||
*/
|
||||
constructor(props) {
|
||||
this.code = props.code
|
||||
this.name = props.name
|
||||
this.description = props.description
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An account in the payment provider
|
||||
*/
|
||||
class RecurlyAccount {
|
||||
/**
|
||||
* @param {object} props
|
||||
* @param {string} props.code
|
||||
* @param {string} props.email
|
||||
* @param {boolean} props.hasPastDueInvoice
|
||||
*/
|
||||
constructor(props) {
|
||||
this.code = props.code
|
||||
this.email = props.email
|
||||
this.hasPastDueInvoice = props.hasPastDueInvoice ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given plan code is a standalone AI plan
|
||||
*
|
||||
@@ -446,6 +490,8 @@ module.exports = {
|
||||
CreditCardPaymentMethod,
|
||||
RecurlyAddOn,
|
||||
RecurlyPlan,
|
||||
RecurlyCoupon,
|
||||
RecurlyAccount,
|
||||
isStandaloneAiAddOnPlanCode,
|
||||
RecurlyImmediateCharge,
|
||||
}
|
||||
|
||||
@@ -547,27 +547,6 @@ const promises = {
|
||||
|
||||
updateAccountEmailAddress,
|
||||
|
||||
async getAccountActiveCoupons(accountId) {
|
||||
const { body } = await RecurlyWrapper.promises.apiRequest({
|
||||
url: `accounts/${accountId}/redemptions`,
|
||||
})
|
||||
|
||||
const redemptions = await RecurlyWrapper.promises._parseRedemptionsXml(body)
|
||||
|
||||
const activeRedemptions = redemptions.filter(
|
||||
redemption => redemption.state === 'active'
|
||||
)
|
||||
const couponCodes = activeRedemptions.map(
|
||||
redemption => redemption.coupon_code
|
||||
)
|
||||
|
||||
return await Promise.all(
|
||||
couponCodes.map(couponCode =>
|
||||
RecurlyWrapper.promises.getCoupon(couponCode)
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
async getCoupon(couponCode) {
|
||||
const opts = { url: `coupons/${couponCode}` }
|
||||
const { body } = await RecurlyWrapper.promises.apiRequest(opts)
|
||||
@@ -904,7 +883,6 @@ const RecurlyWrapper = {
|
||||
_buildXml,
|
||||
_parseXml: callbackify(promises._parseXml),
|
||||
createFixedAmountCoupon: callbackify(promises.createFixedAmountCoupon),
|
||||
getAccountActiveCoupons: callbackify(promises.getAccountActiveCoupons),
|
||||
getBillingInfo: callbackify(promises.getBillingInfo),
|
||||
getPaginatedEndpoint: callbackify(promises.getPaginatedEndpoint),
|
||||
getSubscription: callbackify(promises.getSubscription),
|
||||
|
||||
@@ -23,6 +23,8 @@ const {
|
||||
V1ConnectionError,
|
||||
} = require('../Errors/Errors')
|
||||
const FeaturesHelper = require('./FeaturesHelper')
|
||||
const PaymentService = require('./PaymentService')
|
||||
const { formatCurrency } = require('../../util/currency')
|
||||
|
||||
/**
|
||||
* @import { Subscription } from "../../../../types/project/dashboard/subscription"
|
||||
@@ -80,38 +82,16 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
|
||||
currentInstitutionsWithLicence,
|
||||
managedInstitutions,
|
||||
managedPublishers,
|
||||
recurlySubscription,
|
||||
recurlyCoupons,
|
||||
paymentRecord,
|
||||
plan,
|
||||
} = await async.auto({
|
||||
personalSubscription(cb) {
|
||||
SubscriptionLocator.getUsersSubscription(user, cb)
|
||||
},
|
||||
recurlySubscription: [
|
||||
paymentRecord: [
|
||||
'personalSubscription',
|
||||
({ personalSubscription }, cb) => {
|
||||
if (
|
||||
personalSubscription == null ||
|
||||
personalSubscription.recurlySubscription_id == null ||
|
||||
personalSubscription.recurlySubscription_id === ''
|
||||
) {
|
||||
return cb(null, null)
|
||||
}
|
||||
RecurlyWrapper.getSubscription(
|
||||
personalSubscription.recurlySubscription_id,
|
||||
{ includeAccount: true },
|
||||
cb
|
||||
)
|
||||
},
|
||||
],
|
||||
recurlyCoupons: [
|
||||
'recurlySubscription',
|
||||
({ recurlySubscription }, cb) => {
|
||||
if (!recurlySubscription) {
|
||||
return cb(null, null)
|
||||
}
|
||||
const accountId = recurlySubscription.account.account_code
|
||||
RecurlyWrapper.getAccountActiveCoupons(accountId, cb)
|
||||
PaymentService.getPaymentFromRecord(personalSubscription, cb)
|
||||
},
|
||||
],
|
||||
plan: [
|
||||
@@ -158,6 +138,8 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
|
||||
},
|
||||
})
|
||||
|
||||
const recurlySubscription = paymentRecord && paymentRecord.subscription
|
||||
|
||||
if (memberGroupSubscriptions == null) {
|
||||
memberGroupSubscriptions = []
|
||||
} else {
|
||||
@@ -216,9 +198,6 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
|
||||
if (managedInstitutions == null) {
|
||||
managedInstitutions = []
|
||||
}
|
||||
if (recurlyCoupons == null) {
|
||||
recurlyCoupons = []
|
||||
}
|
||||
|
||||
personalSubscription = serializeMongooseObject(personalSubscription)
|
||||
|
||||
@@ -251,8 +230,8 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
|
||||
// The MEMBERS_LIMIT_ADD_ON_CODE is considered as part of the new plan model
|
||||
const allAddOnsPriceInCentsExceptAdditionalLicensePrice = addOns.reduce(
|
||||
(prev, curr) => {
|
||||
return curr.add_on_code !== MEMBERS_LIMIT_ADD_ON_CODE
|
||||
? curr.quantity * curr.unit_amount_in_cents + prev
|
||||
return curr.code !== MEMBERS_LIMIT_ADD_ON_CODE
|
||||
? curr.quantity * curr.unitPrice + prev
|
||||
: prev
|
||||
},
|
||||
0
|
||||
@@ -261,7 +240,7 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
|
||||
allAddOnsPriceInCentsExceptAdditionalLicensePrice +
|
||||
allAddOnsPriceInCentsExceptAdditionalLicensePrice * taxRate
|
||||
|
||||
return SubscriptionFormatters.formatPriceLocalized(
|
||||
return formatCurrency(
|
||||
totalPlanPriceInCents -
|
||||
allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice,
|
||||
recurlySubscription.currency,
|
||||
@@ -271,12 +250,12 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
|
||||
|
||||
function getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns = []) {
|
||||
return addOns.reduce((prev, curr) => {
|
||||
if (curr.add_on_code !== MEMBERS_LIMIT_ADD_ON_CODE) {
|
||||
const priceInCents = curr.quantity * curr.unit_amount_in_cents
|
||||
if (curr.code !== MEMBERS_LIMIT_ADD_ON_CODE) {
|
||||
const priceInCents = curr.quantity * curr.unitPrice
|
||||
const totalPriceInCents = priceInCents + priceInCents * taxRate
|
||||
|
||||
if (totalPriceInCents > 0) {
|
||||
prev[curr.add_on_code] = SubscriptionFormatters.formatPriceLocalized(
|
||||
prev[curr.code] = formatCurrency(
|
||||
totalPriceInCents,
|
||||
recurlySubscription.currency,
|
||||
locale
|
||||
@@ -289,19 +268,17 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
|
||||
}
|
||||
|
||||
if (personalSubscription && recurlySubscription) {
|
||||
const tax = recurlySubscription.tax_in_cents || 0
|
||||
const tax = recurlySubscription.taxAmount || 0
|
||||
// Some plans allow adding more seats than the base plan provides.
|
||||
// This is recorded as a subscription add on.
|
||||
// Note: tax_in_cents already includes the tax for any addon.
|
||||
// Note: taxAmount already includes the tax for any addon.
|
||||
let addOnPrice = 0
|
||||
let additionalLicenses = 0
|
||||
const addOns = recurlySubscription.subscription_add_ons || []
|
||||
const taxRate = recurlySubscription.tax_rate
|
||||
? parseFloat(recurlySubscription.tax_rate._)
|
||||
: 0
|
||||
const addOns = recurlySubscription.addOns || []
|
||||
const taxRate = recurlySubscription.taxRate
|
||||
addOns.forEach(addOn => {
|
||||
addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
|
||||
if (addOn.add_on_code === plan.membersLimitAddOn) {
|
||||
addOnPrice += addOn.quantity * addOn.unitPrice
|
||||
if (addOn.code === plan.membersLimitAddOn) {
|
||||
additionalLicenses += addOn.quantity
|
||||
}
|
||||
})
|
||||
@@ -315,40 +292,38 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
|
||||
addOns,
|
||||
totalLicenses,
|
||||
nextPaymentDueAt: SubscriptionFormatters.formatDateTime(
|
||||
recurlySubscription.current_period_ends_at
|
||||
recurlySubscription.periodEnd
|
||||
),
|
||||
nextPaymentDueDate: SubscriptionFormatters.formatDate(
|
||||
recurlySubscription.current_period_ends_at
|
||||
recurlySubscription.periodEnd
|
||||
),
|
||||
currency: recurlySubscription.currency,
|
||||
state: recurlySubscription.state,
|
||||
trialEndsAtFormatted: SubscriptionFormatters.formatDateTime(
|
||||
recurlySubscription.trial_ends_at
|
||||
recurlySubscription.trialPeriodEnd
|
||||
),
|
||||
trial_ends_at: recurlySubscription.trial_ends_at,
|
||||
activeCoupons: recurlyCoupons,
|
||||
account: recurlySubscription.account,
|
||||
pausedAt: recurlySubscription.paused_at,
|
||||
remainingPauseCycles: recurlySubscription.remaining_pause_cycles,
|
||||
trialEndsAt: recurlySubscription.trialPeriodEnd,
|
||||
activeCoupons: paymentRecord.coupons,
|
||||
accountEmail: paymentRecord.account.email,
|
||||
hasPastDueInvoice: paymentRecord.account.hasPastDueInvoice,
|
||||
pausedAt: recurlySubscription.pausePeriodStart,
|
||||
remainingPauseCycles: recurlySubscription.remainingPauseCycles,
|
||||
}
|
||||
if (recurlySubscription.pending_subscription) {
|
||||
const pendingPlan = PlansLocator.findLocalPlanInSettings(
|
||||
recurlySubscription.pending_subscription.plan.plan_code
|
||||
)
|
||||
if (recurlySubscription.pendingChange) {
|
||||
const pendingPlanCode = recurlySubscription.pendingChange.nextPlanCode
|
||||
const pendingPlan = PlansLocator.findLocalPlanInSettings(pendingPlanCode)
|
||||
if (pendingPlan == null) {
|
||||
throw new Error(
|
||||
`No plan found for planCode '${personalSubscription.planCode}'`
|
||||
)
|
||||
throw new Error(`No plan found for planCode '${pendingPlanCode}'`)
|
||||
}
|
||||
let pendingAdditionalLicenses = 0
|
||||
let pendingAddOnTax = 0
|
||||
let pendingAddOnPrice = 0
|
||||
if (recurlySubscription.pending_subscription.subscription_add_ons) {
|
||||
if (recurlySubscription.pendingChange.nextAddOns) {
|
||||
const pendingRecurlyAddons =
|
||||
recurlySubscription.pending_subscription.subscription_add_ons
|
||||
recurlySubscription.pendingChange.nextAddOns
|
||||
pendingRecurlyAddons.forEach(addOn => {
|
||||
pendingAddOnPrice += addOn.quantity * addOn.unit_amount_in_cents
|
||||
if (addOn.add_on_code === pendingPlan.membersLimitAddOn) {
|
||||
pendingAddOnPrice += addOn.quantity * addOn.unitPrice
|
||||
if (addOn.code === pendingPlan.membersLimitAddOn) {
|
||||
pendingAdditionalLicenses += addOn.quantity
|
||||
}
|
||||
})
|
||||
@@ -359,34 +334,33 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
|
||||
}
|
||||
const pendingSubscriptionTax =
|
||||
personalSubscription.recurly.taxRate *
|
||||
recurlySubscription.pending_subscription.unit_amount_in_cents
|
||||
const totalPriceInCents =
|
||||
recurlySubscription.pending_subscription.unit_amount_in_cents +
|
||||
recurlySubscription.pendingChange.nextPlanPrice
|
||||
const totalPrice =
|
||||
recurlySubscription.pendingChange.nextPlanPrice +
|
||||
pendingAddOnPrice +
|
||||
pendingAddOnTax +
|
||||
pendingSubscriptionTax
|
||||
personalSubscription.recurly.displayPrice =
|
||||
SubscriptionFormatters.formatPriceLocalized(
|
||||
totalPriceInCents,
|
||||
recurlySubscription.currency,
|
||||
locale
|
||||
)
|
||||
personalSubscription.recurly.currentPlanDisplayPrice =
|
||||
SubscriptionFormatters.formatPriceLocalized(
|
||||
recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
|
||||
recurlySubscription.currency,
|
||||
locale
|
||||
)
|
||||
|
||||
personalSubscription.recurly.displayPrice = formatCurrency(
|
||||
totalPrice,
|
||||
recurlySubscription.currency,
|
||||
locale
|
||||
)
|
||||
personalSubscription.recurly.currentPlanDisplayPrice = formatCurrency(
|
||||
recurlySubscription.planPrice + addOnPrice + tax,
|
||||
recurlySubscription.currency,
|
||||
locale
|
||||
)
|
||||
personalSubscription.recurly.planOnlyDisplayPrice =
|
||||
getPlanOnlyDisplayPrice(
|
||||
totalPriceInCents,
|
||||
totalPrice,
|
||||
taxRate,
|
||||
recurlySubscription.pending_subscription.subscription_add_ons
|
||||
recurlySubscription.pendingChange.nextAddOns
|
||||
)
|
||||
personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense =
|
||||
getAddOnDisplayPricesWithoutAdditionalLicense(
|
||||
taxRate,
|
||||
recurlySubscription.pending_subscription.subscription_add_ons
|
||||
recurlySubscription.pendingChange.nextAddOns
|
||||
)
|
||||
const pendingTotalLicenses =
|
||||
(pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
|
||||
@@ -395,16 +369,14 @@ async function buildUsersSubscriptionViewModel(user, locale = 'en') {
|
||||
personalSubscription.recurly.pendingTotalLicenses = pendingTotalLicenses
|
||||
personalSubscription.pendingPlan = pendingPlan
|
||||
} else {
|
||||
const totalPriceInCents =
|
||||
recurlySubscription.unit_amount_in_cents + addOnPrice + tax
|
||||
personalSubscription.recurly.displayPrice =
|
||||
SubscriptionFormatters.formatPriceLocalized(
|
||||
totalPriceInCents,
|
||||
recurlySubscription.currency,
|
||||
locale
|
||||
)
|
||||
const totalPrice = recurlySubscription.planPrice + addOnPrice + tax
|
||||
personalSubscription.recurly.displayPrice = formatCurrency(
|
||||
totalPrice,
|
||||
recurlySubscription.currency,
|
||||
locale
|
||||
)
|
||||
personalSubscription.recurly.planOnlyDisplayPrice =
|
||||
getPlanOnlyDisplayPrice(totalPriceInCents, taxRate, addOns)
|
||||
getPlanOnlyDisplayPrice(totalPrice, taxRate, addOns)
|
||||
personalSubscription.recurly.addOnDisplayPricesWithoutAdditionalLicense =
|
||||
getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns)
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ export default function PauseSubscriptionModal() {
|
||||
plan_code: subscription?.planCode,
|
||||
is_trial:
|
||||
subscription?.recurly.trialEndsAtFormatted &&
|
||||
subscription?.recurly.trial_ends_at &&
|
||||
new Date(subscription.recurly.trial_ends_at).getTime() > Date.now(),
|
||||
subscription?.recurly.trialEndsAt &&
|
||||
new Date(subscription.recurly.trialEndsAt).getTime() > Date.now(),
|
||||
})
|
||||
setShowCancellation(true)
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ function PersonalSubscriptionRecurlySyncEmail() {
|
||||
|
||||
if (!personalSubscription || !('recurly' in personalSubscription)) return null
|
||||
|
||||
const recurlyEmail = personalSubscription.recurly.account.email
|
||||
const recurlyEmail = personalSubscription.recurly.accountEmail
|
||||
|
||||
if (!userEmail || recurlyEmail === userEmail) return null
|
||||
|
||||
|
||||
+1
-2
@@ -75,8 +75,7 @@ function PersonalSubscription() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{personalSubscription.recurly.account.has_past_due_invoice._ ===
|
||||
'true' && (
|
||||
{personalSubscription.recurly.hasPastDueInvoice && (
|
||||
<PastDueSubscriptionAlert subscription={personalSubscription} />
|
||||
)}
|
||||
<PersonalSubscriptionStates
|
||||
|
||||
+6
-7
@@ -171,7 +171,7 @@ export function ActiveSubscriptionNew({
|
||||
subscription.pendingPlan.name !== subscription.plan.name && (
|
||||
<p className="mb-1">{t('want_change_to_apply_before_plan_end')}</p>
|
||||
)}
|
||||
{isInFreeTrial(subscription.recurly.trial_ends_at) &&
|
||||
{isInFreeTrial(subscription.recurly.trialEndsAt) &&
|
||||
subscription.recurly.trialEndsAtFormatted && (
|
||||
<TrialEnding
|
||||
trialEndsAtFormatted={subscription.recurly.trialEndsAtFormatted}
|
||||
@@ -301,12 +301,11 @@ function PlanActions({
|
||||
<FlexibleGroupLicensingActions subscription={subscription} />
|
||||
) : (
|
||||
<>
|
||||
{!hasPendingPause &&
|
||||
subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
|
||||
<OLButton variant="secondary" onClick={handlePlanChange}>
|
||||
{t('change_plan')}
|
||||
</OLButton>
|
||||
)}
|
||||
{!hasPendingPause && !subscription.recurly.hasPastDueInvoice && (
|
||||
<OLButton variant="secondary" onClick={handlePlanChange}>
|
||||
{t('change_plan')}
|
||||
</OLButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasPendingPause && (
|
||||
|
||||
+2
-2
@@ -108,7 +108,7 @@ export function ActiveSubscription({
|
||||
{!recurlyLoadError &&
|
||||
!subscription.groupPlan &&
|
||||
!hasPendingPause &&
|
||||
subscription.recurly.account.has_past_due_invoice._ !== 'true' && (
|
||||
!subscription.recurly.hasPastDueInvoice && (
|
||||
<>
|
||||
{' '}
|
||||
<OLButton
|
||||
@@ -128,7 +128,7 @@ export function ActiveSubscription({
|
||||
{(!subscription.pendingPlan ||
|
||||
subscription.pendingPlan.name === subscription.plan.name) &&
|
||||
subscription.plan.groupPlan && <ContactSupportToChangeGroupPlan />}
|
||||
{isInFreeTrial(subscription.recurly.trial_ends_at) &&
|
||||
{isInFreeTrial(subscription.recurly.trialEndsAt) &&
|
||||
subscription.recurly.trialEndsAtFormatted && (
|
||||
<TrialEnding
|
||||
trialEndsAtFormatted={subscription.recurly.trialEndsAtFormatted}
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ function AddOns({
|
||||
pendingCancellation={
|
||||
subscription.pendingPlan !== undefined &&
|
||||
(subscription.pendingPlan.addOns ?? []).every(
|
||||
pendingAddOn => pendingAddOn.addOnCode !== addOn.addOnCode
|
||||
pendingAddOn => pendingAddOn.code !== addOn.addOnCode
|
||||
)
|
||||
}
|
||||
displayPrice={addOnsDisplayPrices[addOn.addOnCode]}
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ export function CancelSubscription() {
|
||||
const showDowngrade = showDowngradeOption(
|
||||
personalSubscription.plan.planCode,
|
||||
personalSubscription.plan.groupPlan,
|
||||
personalSubscription.recurly.trial_ends_at,
|
||||
personalSubscription.recurly.trialEndsAt,
|
||||
personalSubscription.recurly.pausedAt,
|
||||
personalSubscription.recurly.remainingPauseCycles
|
||||
)
|
||||
|
||||
+2
-2
@@ -17,8 +17,8 @@ export function CancelSubscriptionButton() {
|
||||
const subscription = personalSubscription as RecurlySubscription
|
||||
const isInTrial =
|
||||
subscription?.recurly.trialEndsAtFormatted &&
|
||||
subscription?.recurly.trial_ends_at &&
|
||||
new Date(subscription.recurly.trial_ends_at).getTime() > Date.now()
|
||||
subscription?.recurly.trialEndsAt &&
|
||||
new Date(subscription.recurly.trialEndsAt).getTime() > Date.now()
|
||||
const hasPendingOrActivePause =
|
||||
subscription.recurly.state === 'paused' ||
|
||||
(subscription.recurly.state === 'active' &&
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ export function ChangeToGroupPlan() {
|
||||
const { handleOpenModal, personalSubscription } =
|
||||
useSubscriptionDashboardContext()
|
||||
|
||||
// TODO: Better way to get RecurlySubscription/trial_ends_at
|
||||
// TODO: Better way to get RecurlySubscription/trialEndsAt
|
||||
const subscription =
|
||||
personalSubscription && 'recurly' in personalSubscription
|
||||
? (personalSubscription as RecurlySubscription)
|
||||
@@ -25,7 +25,7 @@ export function ChangeToGroupPlan() {
|
||||
<h2 style={{ marginTop: 0 }}>{t('looking_multiple_licenses')}</h2>
|
||||
<p style={{ margin: 0 }}>{t('reduce_costs_group_licenses')}</p>
|
||||
<br />
|
||||
{isInFreeTrial(subscription?.recurly?.trial_ends_at) ? (
|
||||
{isInFreeTrial(subscription?.recurly?.trialEndsAt) ? (
|
||||
<>
|
||||
<OLTooltip
|
||||
id="disabled-change-to-group-plan"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export function PendingPlanChange({
|
||||
|
||||
const pendingAiAddonCancellation =
|
||||
hasAiAddon &&
|
||||
!pendingPlan.addOns?.some(addOn => addOn.add_on_code === AI_ADD_ON_CODE)
|
||||
!pendingPlan.addOns?.some(addOn => addOn.code === AI_ADD_ON_CODE)
|
||||
|
||||
const pendingAdditionalLicenses =
|
||||
(subscription.recurly.pendingAdditionalLicenses &&
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ function SubscriptionRemainder({
|
||||
}: SubscriptionRemainderProps) {
|
||||
const stillInATrial =
|
||||
subscription.recurly.trialEndsAtFormatted &&
|
||||
subscription.recurly.trial_ends_at &&
|
||||
new Date(subscription.recurly.trial_ends_at).getTime() > Date.now()
|
||||
subscription.recurly.trialEndsAt &&
|
||||
new Date(subscription.recurly.trialEndsAt).getTime() > Date.now()
|
||||
|
||||
const terminationDate = hideTime
|
||||
? subscription.recurly.nextPaymentDueDate
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export function PriceExceptions({ subscription }: PriceExceptionsProps) {
|
||||
<i>* {t('coupons_not_included')}:</i>
|
||||
<ul>
|
||||
{activeCoupons.map(coupon => (
|
||||
<li key={coupon.id}>
|
||||
<li key={coupon.code}>
|
||||
<i>{coupon.description || coupon.name}</i>
|
||||
</li>
|
||||
))}
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ function SuccessfulSubscription() {
|
||||
type="success"
|
||||
content={
|
||||
<>
|
||||
{subscription.recurly.trial_ends_at && (
|
||||
{subscription.recurly.trialEndsAt && (
|
||||
<>
|
||||
<p>
|
||||
<Trans
|
||||
|
||||
+1
-1
@@ -208,7 +208,7 @@ describe('<PersonalSubscription />', function () {
|
||||
/your billing email address is currently/i
|
||||
).textContent
|
||||
expect(billingText).to.contain(
|
||||
`Your billing email address is currently ${annualActiveSubscription.recurly.account.email}.` +
|
||||
`Your billing email address is currently ${annualActiveSubscription.recurly.accountEmail}.` +
|
||||
` If needed you can update your billing address to ${usersEmail}`
|
||||
)
|
||||
|
||||
|
||||
+3
-1
@@ -110,7 +110,7 @@ describe('<ActiveSubscription />', function () {
|
||||
JSON.parse(JSON.stringify(annualActiveSubscription))
|
||||
)
|
||||
|
||||
activePastDueSubscription.recurly.account.has_past_due_invoice._ = 'true'
|
||||
activePastDueSubscription.recurly.hasPastDueInvoice = true
|
||||
|
||||
renderActiveSubscription(activePastDueSubscription)
|
||||
|
||||
@@ -179,6 +179,8 @@ describe('<ActiveSubscription />', function () {
|
||||
subscriptionWithActiveCoupons.recurly.activeCoupons = [
|
||||
{
|
||||
name: 'fake coupon name',
|
||||
code: 'fake-coupon',
|
||||
description: '',
|
||||
},
|
||||
]
|
||||
renderActiveSubscription(subscriptionWithActiveCoupons)
|
||||
|
||||
@@ -45,14 +45,10 @@ export const annualActiveSubscription: RecurlySubscription = {
|
||||
currency: 'USD',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: null,
|
||||
trial_ends_at: null,
|
||||
trialEndsAt: null,
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
email: 'fake@example.com',
|
||||
created_at: '2024-12-31T09:40:27.000Z',
|
||||
has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
|
||||
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
|
||||
},
|
||||
accountEmail: 'fake@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
displayPrice: '$199.00',
|
||||
planOnlyDisplayPrice: '',
|
||||
addOns: [],
|
||||
@@ -90,14 +86,10 @@ export const annualActiveSubscriptionEuro: RecurlySubscription = {
|
||||
currency: 'EUR',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: null,
|
||||
trial_ends_at: null,
|
||||
trialEndsAt: null,
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
email: 'fake@example.com',
|
||||
created_at: '2024-12-31T09:40:27.000Z',
|
||||
has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
|
||||
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
|
||||
},
|
||||
accountEmail: 'fake@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
displayPrice: '€221.96',
|
||||
planOnlyDisplayPrice: '',
|
||||
addOns: [],
|
||||
@@ -134,14 +126,10 @@ export const annualActiveSubscriptionPro: RecurlySubscription = {
|
||||
currency: 'USD',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: null,
|
||||
trial_ends_at: null,
|
||||
trialEndsAt: null,
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
email: 'fake@example.com',
|
||||
created_at: '2024-12-31T09:40:27.000Z',
|
||||
has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
|
||||
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
|
||||
},
|
||||
accountEmail: 'fake@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
displayPrice: '$42.00',
|
||||
planOnlyDisplayPrice: '',
|
||||
addOns: [],
|
||||
@@ -179,14 +167,10 @@ export const pastDueExpiredSubscription: RecurlySubscription = {
|
||||
currency: 'USD',
|
||||
state: 'expired',
|
||||
trialEndsAtFormatted: null,
|
||||
trial_ends_at: null,
|
||||
trialEndsAt: null,
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
email: 'fake@example.com',
|
||||
created_at: '2024-12-31T09:40:27.000Z',
|
||||
has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
|
||||
has_past_due_invoice: { _: 'true', $: { type: 'boolean' } },
|
||||
},
|
||||
accountEmail: 'fake@example.com',
|
||||
hasPastDueInvoice: true,
|
||||
displayPrice: '$199.00',
|
||||
planOnlyDisplayPrice: '',
|
||||
addOns: [],
|
||||
@@ -224,14 +208,10 @@ export const canceledSubscription: RecurlySubscription = {
|
||||
currency: 'USD',
|
||||
state: 'canceled',
|
||||
trialEndsAtFormatted: null,
|
||||
trial_ends_at: null,
|
||||
trialEndsAt: null,
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
email: 'fake@example.com',
|
||||
created_at: '2024-12-31T09:40:27.000Z',
|
||||
has_canceled_subscription: { _: 'true', $: { type: 'boolean' } },
|
||||
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
|
||||
},
|
||||
accountEmail: 'fake@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
displayPrice: '$199.00',
|
||||
planOnlyDisplayPrice: '',
|
||||
addOns: [],
|
||||
@@ -269,14 +249,10 @@ export const pendingSubscriptionChange: RecurlySubscription = {
|
||||
currency: 'USD',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: null,
|
||||
trial_ends_at: null,
|
||||
trialEndsAt: null,
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
email: 'fake@example.com',
|
||||
created_at: '2024-12-31T09:40:27.000Z',
|
||||
has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
|
||||
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
|
||||
},
|
||||
accountEmail: 'fake@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
displayPrice: '$199.00',
|
||||
planOnlyDisplayPrice: '',
|
||||
addOns: [],
|
||||
@@ -325,14 +301,10 @@ export const groupActiveSubscription: GroupSubscription = {
|
||||
currency: 'USD',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: null,
|
||||
trial_ends_at: null,
|
||||
trialEndsAt: null,
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
email: 'fake@example.com',
|
||||
created_at: '2024-12-31T09:40:27.000Z',
|
||||
has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
|
||||
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
|
||||
},
|
||||
accountEmail: 'fake@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
displayPrice: '$1290.00',
|
||||
planOnlyDisplayPrice: '',
|
||||
addOns: [],
|
||||
@@ -375,24 +347,10 @@ export const groupActiveSubscriptionWithPendingLicenseChange: GroupSubscription
|
||||
currency: 'USD',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: null,
|
||||
trial_ends_at: null,
|
||||
trialEndsAt: null,
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
email: 'fake@example.com',
|
||||
created_at: '2024-12-31T09:40:27.000Z',
|
||||
has_canceled_subscription: {
|
||||
_: 'false',
|
||||
$: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
has_past_due_invoice: {
|
||||
_: 'false',
|
||||
$: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
accountEmail: 'fake@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
displayPrice: '$2967.00',
|
||||
currentPlanDisplayPrice: '$2709.00',
|
||||
pendingAdditionalLicenses: 13,
|
||||
@@ -443,24 +401,10 @@ export const trialSubscription: RecurlySubscription = {
|
||||
currency: 'USD',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: sevenDaysFromTodayFormatted,
|
||||
trial_ends_at: new Date(sevenDaysFromToday).toString(),
|
||||
trialEndsAt: new Date(sevenDaysFromToday).toString(),
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
email: 'fake@example.com',
|
||||
created_at: '2024-12-31T09:40:27.000Z',
|
||||
has_canceled_subscription: {
|
||||
_: 'false',
|
||||
$: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
has_past_due_invoice: {
|
||||
_: 'false',
|
||||
$: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
accountEmail: 'fake@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
displayPrice: '$14.00',
|
||||
planOnlyDisplayPrice: '',
|
||||
addOns: [],
|
||||
@@ -519,24 +463,10 @@ export const trialCollaboratorSubscription: RecurlySubscription = {
|
||||
currency: 'USD',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: sevenDaysFromTodayFormatted,
|
||||
trial_ends_at: new Date(sevenDaysFromToday).toString(),
|
||||
trialEndsAt: new Date(sevenDaysFromToday).toString(),
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
email: 'foo@example.com',
|
||||
created_at: '2024-12-31T09:40:27.000Z',
|
||||
has_canceled_subscription: {
|
||||
_: 'false',
|
||||
$: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
has_past_due_invoice: {
|
||||
_: 'false',
|
||||
$: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
accountEmail: 'foo@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
displayPrice: '$21.00',
|
||||
planOnlyDisplayPrice: '',
|
||||
addOns: [],
|
||||
@@ -573,14 +503,10 @@ export const monthlyActiveCollaborator: RecurlySubscription = {
|
||||
currency: 'USD',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: null,
|
||||
trial_ends_at: null,
|
||||
trialEndsAt: null,
|
||||
activeCoupons: [],
|
||||
account: {
|
||||
email: 'foo@example.com',
|
||||
created_at: '2024-12-31T09:40:27.000Z',
|
||||
has_canceled_subscription: { _: 'false', $: { type: 'boolean' } },
|
||||
has_past_due_invoice: { _: 'false', $: { type: 'boolean' } },
|
||||
},
|
||||
accountEmail: 'foo@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
displayPrice: '$21.00',
|
||||
planOnlyDisplayPrice: '',
|
||||
addOns: [],
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const {
|
||||
RecurlySubscription,
|
||||
RecurlyAccount,
|
||||
RecurlyCoupon,
|
||||
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
|
||||
|
||||
const MODULE_PATH = '../../../../app/src/Features/Subscription/PaymentService'
|
||||
|
||||
describe('PaymentService', function () {
|
||||
beforeEach(function () {
|
||||
this.user = {
|
||||
_id: '123456',
|
||||
}
|
||||
this.recurlySubscription = new RecurlySubscription({
|
||||
id: 'subscription-id',
|
||||
userId: this.user._id,
|
||||
currency: 'EUR',
|
||||
planCode: 'plan-code',
|
||||
planName: 'plan-name',
|
||||
planPrice: 13,
|
||||
addOns: [],
|
||||
subtotal: 15,
|
||||
taxRate: 0.1,
|
||||
taxAmount: 1.5,
|
||||
total: 14.5,
|
||||
periodStart: new Date(),
|
||||
periodEnd: new Date(),
|
||||
collectionMethod: 'automatic',
|
||||
})
|
||||
this.recurlyAccount = new RecurlyAccount({
|
||||
code: this.user._id,
|
||||
email: 'example@example.com',
|
||||
hasPastDueInvoice: true,
|
||||
})
|
||||
this.recurlyCoupons = [
|
||||
new RecurlyCoupon({
|
||||
code: 'coupon-code',
|
||||
name: 'coupon name',
|
||||
description: 'coupon description',
|
||||
}),
|
||||
]
|
||||
this.mongoSubscription = {
|
||||
admin_id: this.user,
|
||||
recurlySubscription_id: this.recurlySubscription.id,
|
||||
}
|
||||
this.RecurlyClient = {
|
||||
promises: {
|
||||
getSubscription: sinon.stub(),
|
||||
getAccountForUserId: sinon.stub(),
|
||||
getActiveCouponsForUserId: sinon.stub(),
|
||||
},
|
||||
}
|
||||
return (this.PaymentService = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
'./RecurlyClient': this.RecurlyClient,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
describe('getPaymentFromRecord', function () {
|
||||
it('should return null for a missing subscription', async function () {
|
||||
const response =
|
||||
await this.PaymentService.promises.getPaymentFromRecord(null)
|
||||
expect(response).to.equal(null)
|
||||
})
|
||||
|
||||
it('should return null if payment service subscription id is missing', async function () {
|
||||
this.mongoSubscription.recurlySubscription_id = null
|
||||
const response = await this.PaymentService.promises.getPaymentFromRecord(
|
||||
this.mongoSubscription
|
||||
)
|
||||
expect(response).to.equal(null)
|
||||
})
|
||||
|
||||
it('should return the subscription', async function () {
|
||||
this.RecurlyClient.promises.getSubscription.returns(
|
||||
this.recurlySubscription
|
||||
)
|
||||
const response = await this.PaymentService.promises.getPaymentFromRecord(
|
||||
this.mongoSubscription
|
||||
)
|
||||
expect(response.subscription).to.deep.equal(this.recurlySubscription)
|
||||
})
|
||||
|
||||
it('should return account information if found', async function () {
|
||||
this.RecurlyClient.promises.getSubscription.returns(
|
||||
this.recurlySubscription
|
||||
)
|
||||
this.RecurlyClient.promises.getAccountForUserId.returns(
|
||||
this.recurlyAccount
|
||||
)
|
||||
const response = await this.PaymentService.promises.getPaymentFromRecord(
|
||||
this.mongoSubscription
|
||||
)
|
||||
expect(response.account.email).to.equal(this.recurlyAccount.email)
|
||||
expect(response.account.hasPastDueInvoice).to.equal(
|
||||
this.recurlyAccount.hasPastDueInvoice
|
||||
)
|
||||
})
|
||||
|
||||
it('should include coupons if found', async function () {
|
||||
this.RecurlyClient.promises.getSubscription.returns(
|
||||
this.recurlySubscription
|
||||
)
|
||||
this.RecurlyClient.promises.getActiveCouponsForUserId.returns(
|
||||
this.recurlyCoupons
|
||||
)
|
||||
const response = await this.PaymentService.promises.getPaymentFromRecord(
|
||||
this.mongoSubscription
|
||||
)
|
||||
expect(response.coupons).to.deep.equal(this.recurlyCoupons)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,8 @@ const {
|
||||
RecurlySubscription,
|
||||
RecurlySubscriptionChangeRequest,
|
||||
RecurlySubscriptionAddOnUpdate,
|
||||
RecurlyAccount,
|
||||
RecurlyCoupon,
|
||||
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
|
||||
|
||||
const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyClient'
|
||||
@@ -26,7 +28,10 @@ describe('RecurlyClient', function () {
|
||||
this.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' }
|
||||
this.subscriptionChange = { id: 'subscription-change-123' }
|
||||
this.recurlyAccount = new recurly.Account()
|
||||
Object.assign(this.recurlyAccount, { code: this.user._id })
|
||||
Object.assign(this.recurlyAccount, {
|
||||
code: this.user._id,
|
||||
email: this.user.email,
|
||||
})
|
||||
|
||||
this.subscriptionAddOn = {
|
||||
code: 'addon-code',
|
||||
@@ -101,6 +106,7 @@ describe('RecurlyClient', function () {
|
||||
getAccount: sinon.stub(),
|
||||
getBillingInfo: sinon.stub(),
|
||||
listAccountSubscriptions: sinon.stub(),
|
||||
listActiveCouponRedemptions: sinon.stub(),
|
||||
previewSubscriptionChange: sinon.stub(),
|
||||
}
|
||||
this.recurly = {
|
||||
@@ -147,20 +153,24 @@ describe('RecurlyClient', function () {
|
||||
describe('getAccountForUserId', function () {
|
||||
it('should return an Account if one exists', async function () {
|
||||
this.client.getAccount = sinon.stub().resolves(this.recurlyAccount)
|
||||
await expect(
|
||||
this.RecurlyClient.promises.getAccountForUserId(this.user._id)
|
||||
const account = await this.RecurlyClient.promises.getAccountForUserId(
|
||||
this.user._id
|
||||
)
|
||||
.to.eventually.be.an.instanceOf(recurly.Account)
|
||||
.that.has.property('code', this.user._id)
|
||||
const expectedAccount = new RecurlyAccount({
|
||||
code: this.user._id,
|
||||
email: this.user.email,
|
||||
hasPastDueInvoice: false,
|
||||
})
|
||||
expect(account).to.deep.equal(expectedAccount)
|
||||
})
|
||||
|
||||
it('should return nothing if no account found', async function () {
|
||||
it('should return null if no account found', async function () {
|
||||
this.client.getAccount = sinon
|
||||
.stub()
|
||||
.throws(new recurly.errors.NotFoundError())
|
||||
expect(
|
||||
this.RecurlyClient.promises.getAccountForUserId('nonsense')
|
||||
).to.eventually.equal(undefined)
|
||||
const account =
|
||||
await this.RecurlyClient.promises.getAccountForUserId('nonsense')
|
||||
expect(account).to.equal(null)
|
||||
})
|
||||
|
||||
it('should re-throw caught errors', async function () {
|
||||
@@ -189,6 +199,59 @@ describe('RecurlyClient', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActiveCouponsForUserId', function () {
|
||||
it('should return an empty array if no coupons returned', async function () {
|
||||
this.client.listActiveCouponRedemptions.returns({
|
||||
each: async function* () {},
|
||||
})
|
||||
const coupons =
|
||||
await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
|
||||
expect(coupons).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should return a coupons returned by recurly', async function () {
|
||||
const recurlyCoupon = {
|
||||
coupon: {
|
||||
code: 'coupon-code',
|
||||
name: 'Coupon Name',
|
||||
hostedPageDescription: 'hosted page description',
|
||||
invoiceDescription: 'invoice description',
|
||||
},
|
||||
}
|
||||
this.client.listActiveCouponRedemptions.returns({
|
||||
each: async function* () {
|
||||
yield recurlyCoupon
|
||||
},
|
||||
})
|
||||
const coupons =
|
||||
await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
|
||||
const expectedCoupons = [
|
||||
new RecurlyCoupon({
|
||||
code: 'coupon-code',
|
||||
name: 'Coupon Name',
|
||||
description: 'hosted page description',
|
||||
}),
|
||||
]
|
||||
expect(coupons).to.deep.equal(expectedCoupons)
|
||||
})
|
||||
|
||||
it('should not throw for Recurly not found error', async function () {
|
||||
this.client.listActiveCouponRedemptions = sinon
|
||||
.stub()
|
||||
.throws(new recurly.errors.NotFoundError())
|
||||
const coupons =
|
||||
await this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
|
||||
expect(coupons).to.deep.equal([])
|
||||
})
|
||||
|
||||
it('should throw any other API errors', async function () {
|
||||
this.client.listActiveCouponRedemptions = sinon.stub().throws()
|
||||
await expect(
|
||||
this.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
|
||||
).to.eventually.be.rejectedWith(Error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSubscription', function () {
|
||||
it('should return the subscription found by recurly', async function () {
|
||||
this.client.getSubscription = sinon
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
const { assert } = require('chai')
|
||||
const {
|
||||
RecurlyAccount,
|
||||
RecurlySubscription,
|
||||
RecurlySubscriptionAddOn,
|
||||
RecurlySubscriptionChange,
|
||||
} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
|
||||
|
||||
const modulePath =
|
||||
'../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder'
|
||||
|
||||
@@ -26,6 +33,29 @@ describe('SubscriptionViewModelBuilder', function () {
|
||||
state: 'active',
|
||||
},
|
||||
}
|
||||
this.recurlySubscription = new RecurlySubscription({
|
||||
id: this.recurlySubscription_id,
|
||||
userId: this.user._id,
|
||||
currency: 'EUR',
|
||||
planCode: 'plan-code',
|
||||
planName: 'plan-name',
|
||||
planPrice: 13,
|
||||
addOns: [
|
||||
new RecurlySubscriptionAddOn({
|
||||
code: 'addon-code',
|
||||
name: 'addon name',
|
||||
quantity: 1,
|
||||
unitPrice: 2,
|
||||
}),
|
||||
],
|
||||
subtotal: 15,
|
||||
taxRate: 0.1,
|
||||
taxAmount: 1.5,
|
||||
total: 16.5,
|
||||
periodStart: new Date('2025-01-20T12:00:00.000Z'),
|
||||
periodEnd: new Date('2025-02-20T12:00:00.000Z'),
|
||||
collectionMethod: 'automatic',
|
||||
})
|
||||
|
||||
this.individualCustomSubscription = {
|
||||
planCode: this.planCode,
|
||||
@@ -42,6 +72,8 @@ describe('SubscriptionViewModelBuilder', function () {
|
||||
this.groupPlan = {
|
||||
planCode: this.groupPlanCode,
|
||||
features: this.groupPlanFeatures,
|
||||
membersLimit: 4,
|
||||
membersLimitAddOn: 'additional-license',
|
||||
}
|
||||
this.groupSubscription = {
|
||||
planCode: this.groupPlanCode,
|
||||
@@ -75,18 +107,29 @@ describe('SubscriptionViewModelBuilder', function () {
|
||||
getUsersSubscription: sinon.stub().resolves(),
|
||||
getMemberSubscriptions: sinon.stub().resolves(),
|
||||
},
|
||||
getUsersSubscription: sinon.stub().yields(),
|
||||
getMemberSubscriptions: sinon.stub().yields(null, []),
|
||||
getManagedGroupSubscriptions: sinon.stub().yields(null, []),
|
||||
findLocalPlanInSettings: sinon.stub(),
|
||||
}
|
||||
this.InstitutionsGetter = {
|
||||
promises: {
|
||||
getCurrentInstitutionsWithLicence: sinon.stub().resolves(),
|
||||
},
|
||||
getCurrentInstitutionsWithLicence: sinon.stub().yields(null, []),
|
||||
getManagedInstitutions: sinon.stub().yields(null, []),
|
||||
}
|
||||
this.InstitutionsManager = {
|
||||
promises: {
|
||||
fetchV1Data: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
this.PublishersGetter = {
|
||||
promises: {
|
||||
fetchV1Data: sinon.stub().resolves(),
|
||||
},
|
||||
getManagedPublishers: sinon.stub().yields(null, []),
|
||||
}
|
||||
this.RecurlyWrapper = {
|
||||
promises: {
|
||||
getSubscription: sinon.stub().resolves(),
|
||||
@@ -100,6 +143,9 @@ describe('SubscriptionViewModelBuilder', function () {
|
||||
this.PlansLocator = {
|
||||
findLocalPlanInSettings: sinon.stub(),
|
||||
}
|
||||
this.PaymentService = {
|
||||
getPaymentFromRecord: sinon.stub().yields(),
|
||||
}
|
||||
this.SubscriptionViewModelBuilder = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'@overleaf/settings': this.Settings,
|
||||
@@ -109,9 +155,9 @@ describe('SubscriptionViewModelBuilder', function () {
|
||||
'./RecurlyWrapper': this.RecurlyWrapper,
|
||||
'./SubscriptionUpdater': this.SubscriptionUpdater,
|
||||
'./PlansLocator': this.PlansLocator,
|
||||
'./PaymentService': this.PaymentService,
|
||||
'./V1SubscriptionManager': {},
|
||||
'./SubscriptionFormatters': {},
|
||||
'../Publishers/PublishersGetter': {},
|
||||
'../Publishers/PublishersGetter': this.PublishersGetter,
|
||||
'./SubscriptionHelper': {},
|
||||
},
|
||||
})
|
||||
@@ -309,152 +355,270 @@ describe('SubscriptionViewModelBuilder', function () {
|
||||
plan: this.commonsPlan,
|
||||
})
|
||||
})
|
||||
|
||||
describe('with multiple subscriptions', function () {
|
||||
beforeEach(function () {
|
||||
this.SubscriptionLocator.promises.getUsersSubscription
|
||||
.withArgs(this.user)
|
||||
.resolves(this.individualSubscription)
|
||||
this.SubscriptionLocator.promises.getMemberSubscriptions
|
||||
.withArgs(this.user)
|
||||
.resolves([this.groupSubscription])
|
||||
this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
|
||||
.withArgs(this.user._id)
|
||||
.resolves([this.commonsSubscription])
|
||||
})
|
||||
|
||||
it('should return individual when the individual subscription has the best feature set', async function () {
|
||||
this.commonsPlan.features = {
|
||||
compileGroup: 'standard',
|
||||
collaborators: 1,
|
||||
compileTimeout: 60,
|
||||
}
|
||||
|
||||
const usersBestSubscription =
|
||||
await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
|
||||
this.user
|
||||
)
|
||||
|
||||
assert.deepEqual(usersBestSubscription, {
|
||||
type: 'individual',
|
||||
subscription: this.individualSubscription,
|
||||
plan: this.plan,
|
||||
remainingTrialDays: -1,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return group when the group subscription has the best feature set', async function () {
|
||||
this.plan.features = {
|
||||
compileGroup: 'standard',
|
||||
collaborators: 1,
|
||||
compileTimeout: 60,
|
||||
}
|
||||
this.commonsPlan.features = {
|
||||
compileGroup: 'standard',
|
||||
collaborators: 1,
|
||||
compileTimeout: 60,
|
||||
}
|
||||
|
||||
const usersBestSubscription =
|
||||
await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
|
||||
this.user
|
||||
)
|
||||
|
||||
assert.deepEqual(usersBestSubscription, {
|
||||
type: 'group',
|
||||
subscription: {},
|
||||
plan: this.groupPlan,
|
||||
remainingTrialDays: -1,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return commons when the commons affiliation has the best feature set', async function () {
|
||||
this.plan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: 5,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
this.groupPlan.features = {
|
||||
compileGroup: 'standard',
|
||||
collaborators: 1,
|
||||
compileTimeout: 60,
|
||||
}
|
||||
this.commonsPlan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: -1,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
|
||||
const usersBestSubscription =
|
||||
await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
|
||||
this.user
|
||||
)
|
||||
|
||||
assert.deepEqual(usersBestSubscription, {
|
||||
type: 'commons',
|
||||
subscription: this.commonsSubscription,
|
||||
plan: this.commonsPlan,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return individual with equal feature sets', async function () {
|
||||
this.plan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: -1,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
this.groupPlan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: -1,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
this.commonsPlan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: -1,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
|
||||
const usersBestSubscription =
|
||||
await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
|
||||
this.user
|
||||
)
|
||||
|
||||
assert.deepEqual(usersBestSubscription, {
|
||||
type: 'individual',
|
||||
subscription: this.individualSubscription,
|
||||
plan: this.plan,
|
||||
remainingTrialDays: -1,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return group over commons with equal feature sets', async function () {
|
||||
this.plan.features = {
|
||||
compileGroup: 'standard',
|
||||
collaborators: 1,
|
||||
compileTimeout: 60,
|
||||
}
|
||||
this.groupPlan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: -1,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
this.commonsPlan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: -1,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
|
||||
const usersBestSubscription =
|
||||
await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
|
||||
this.user
|
||||
)
|
||||
|
||||
assert.deepEqual(usersBestSubscription, {
|
||||
type: 'group',
|
||||
subscription: {},
|
||||
plan: this.groupPlan,
|
||||
remainingTrialDays: -1,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with multiple subscriptions', function () {
|
||||
beforeEach(function () {
|
||||
this.SubscriptionLocator.promises.getUsersSubscription
|
||||
.withArgs(this.user)
|
||||
.resolves(this.individualSubscription)
|
||||
this.SubscriptionLocator.promises.getMemberSubscriptions
|
||||
.withArgs(this.user)
|
||||
.resolves([this.groupSubscription])
|
||||
this.InstitutionsGetter.promises.getCurrentInstitutionsWithLicence
|
||||
.withArgs(this.user._id)
|
||||
.resolves([this.commonsSubscription])
|
||||
})
|
||||
|
||||
it('should return individual when the individual subscription has the best feature set', async function () {
|
||||
this.commonsPlan.features = {
|
||||
compileGroup: 'standard',
|
||||
collaborators: 1,
|
||||
compileTimeout: 60,
|
||||
}
|
||||
|
||||
const usersBestSubscription =
|
||||
await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
|
||||
this.user
|
||||
describe('buildUsersSubscriptionViewModel', function () {
|
||||
describe('with a recurly subscription', function () {
|
||||
it('adds recurly data to the personal subscription', async function () {
|
||||
this.SubscriptionLocator.getUsersSubscription.yields(
|
||||
null,
|
||||
this.individualSubscription
|
||||
)
|
||||
|
||||
assert.deepEqual(usersBestSubscription, {
|
||||
type: 'individual',
|
||||
subscription: this.individualSubscription,
|
||||
plan: this.plan,
|
||||
remainingTrialDays: -1,
|
||||
this.PaymentService.getPaymentFromRecord.yields(null, {
|
||||
subscription: this.recurlySubscription,
|
||||
account: new RecurlyAccount({
|
||||
email: 'example@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
}),
|
||||
coupons: [],
|
||||
})
|
||||
const result =
|
||||
await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
|
||||
this.user
|
||||
)
|
||||
assert.deepEqual(result.personalSubscription.recurly, {
|
||||
tax: 1.5,
|
||||
taxRate: 0.1,
|
||||
billingDetailsLink: '/user/subscription/recurly/billing-details',
|
||||
accountManagementLink:
|
||||
'/user/subscription/recurly/account-management',
|
||||
additionalLicenses: 0,
|
||||
addOns: [
|
||||
{
|
||||
code: 'addon-code',
|
||||
name: 'addon name',
|
||||
quantity: 1,
|
||||
unitPrice: 2,
|
||||
preTaxTotal: 2,
|
||||
},
|
||||
],
|
||||
totalLicenses: 0,
|
||||
nextPaymentDueAt: 'February 20th, 2025 12:00 PM UTC',
|
||||
nextPaymentDueDate: 'February 20th, 2025',
|
||||
currency: 'EUR',
|
||||
state: 'active',
|
||||
trialEndsAtFormatted: null,
|
||||
trialEndsAt: null,
|
||||
activeCoupons: [],
|
||||
accountEmail: 'example@example.com',
|
||||
hasPastDueInvoice: false,
|
||||
pausedAt: null,
|
||||
remainingPauseCycles: null,
|
||||
displayPrice: '€16.50',
|
||||
planOnlyDisplayPrice: '€14.30',
|
||||
addOnDisplayPricesWithoutAdditionalLicense: {
|
||||
'addon-code': '€2.20',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should return group when the group subscription has the best feature set', async function () {
|
||||
this.plan.features = {
|
||||
compileGroup: 'standard',
|
||||
collaborators: 1,
|
||||
compileTimeout: 60,
|
||||
}
|
||||
this.commonsPlan.features = {
|
||||
compileGroup: 'standard',
|
||||
collaborators: 1,
|
||||
compileTimeout: 60,
|
||||
}
|
||||
|
||||
const usersBestSubscription =
|
||||
await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
|
||||
this.user
|
||||
it('includes pending changes', async function () {
|
||||
this.SubscriptionLocator.getUsersSubscription.yields(
|
||||
null,
|
||||
this.individualSubscription
|
||||
)
|
||||
|
||||
assert.deepEqual(usersBestSubscription, {
|
||||
type: 'group',
|
||||
subscription: {},
|
||||
plan: this.groupPlan,
|
||||
remainingTrialDays: -1,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return commons when the commons affiliation has the best feature set', async function () {
|
||||
this.plan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: 5,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
this.groupPlan.features = {
|
||||
compileGroup: 'standard',
|
||||
collaborators: 1,
|
||||
compileTimeout: 60,
|
||||
}
|
||||
this.commonsPlan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: -1,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
|
||||
const usersBestSubscription =
|
||||
await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
|
||||
this.user
|
||||
this.recurlySubscription.pendingChange = new RecurlySubscriptionChange({
|
||||
subscription: this.recurlySubscription,
|
||||
nextPlanCode: this.groupPlanCode,
|
||||
nextPlanName: 'Group Collaborator (Annual) 4 licenses',
|
||||
nextPlanPrice: 1400,
|
||||
nextAddOns: [
|
||||
new RecurlySubscriptionAddOn({
|
||||
code: 'additional-license',
|
||||
name: 'additional license',
|
||||
quantity: 8,
|
||||
unitPrice: 24.4,
|
||||
}),
|
||||
new RecurlySubscriptionAddOn({
|
||||
code: 'addon-code',
|
||||
name: 'addon name',
|
||||
quantity: 1,
|
||||
unitPrice: 2,
|
||||
}),
|
||||
],
|
||||
})
|
||||
this.PaymentService.getPaymentFromRecord.yields(null, {
|
||||
subscription: this.recurlySubscription,
|
||||
account: {},
|
||||
coupons: [],
|
||||
})
|
||||
const result =
|
||||
await this.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel(
|
||||
this.user
|
||||
)
|
||||
assert.equal(
|
||||
result.personalSubscription.recurly.displayPrice,
|
||||
'€1,756.92'
|
||||
)
|
||||
|
||||
assert.deepEqual(usersBestSubscription, {
|
||||
type: 'commons',
|
||||
subscription: this.commonsSubscription,
|
||||
plan: this.commonsPlan,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return individual with equal feature sets', async function () {
|
||||
this.plan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: -1,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
this.groupPlan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: -1,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
this.commonsPlan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: -1,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
|
||||
const usersBestSubscription =
|
||||
await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
|
||||
this.user
|
||||
assert.equal(
|
||||
result.personalSubscription.recurly.currentPlanDisplayPrice,
|
||||
'€16.50'
|
||||
)
|
||||
|
||||
assert.deepEqual(usersBestSubscription, {
|
||||
type: 'individual',
|
||||
subscription: this.individualSubscription,
|
||||
plan: this.plan,
|
||||
remainingTrialDays: -1,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return group over commons with equal feature sets', async function () {
|
||||
this.plan.features = {
|
||||
compileGroup: 'standard',
|
||||
collaborators: 1,
|
||||
compileTimeout: 60,
|
||||
}
|
||||
this.groupPlan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: -1,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
this.commonsPlan.features = {
|
||||
compileGroup: 'priority',
|
||||
collaborators: -1,
|
||||
compileTimeout: 240,
|
||||
}
|
||||
|
||||
const usersBestSubscription =
|
||||
await this.SubscriptionViewModelBuilder.promises.getBestSubscription(
|
||||
this.user
|
||||
assert.equal(
|
||||
result.personalSubscription.recurly.planOnlyDisplayPrice,
|
||||
'€1,754.72'
|
||||
)
|
||||
assert.deepEqual(
|
||||
result.personalSubscription.recurly
|
||||
.addOnDisplayPricesWithoutAdditionalLicense,
|
||||
{ 'addon-code': '€2.20' }
|
||||
)
|
||||
assert.equal(
|
||||
result.personalSubscription.recurly.pendingAdditionalLicenses,
|
||||
8
|
||||
)
|
||||
assert.equal(
|
||||
result.personalSubscription.recurly.pendingTotalLicenses,
|
||||
12
|
||||
)
|
||||
|
||||
assert.deepEqual(usersBestSubscription, {
|
||||
type: 'group',
|
||||
subscription: {},
|
||||
plan: this.groupPlan,
|
||||
remainingTrialDays: -1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CurrencyCode } from '../currency'
|
||||
import { Nullable } from '../../utils'
|
||||
import { Plan, AddOn, RecurlyAddOn } from '../plan'
|
||||
import { Plan, AddOn, RecurlyAddOn, PendingRecurlyPlan } from '../plan'
|
||||
import { User } from '../../user'
|
||||
|
||||
type SubscriptionState = 'active' | 'canceled' | 'expired' | 'paused'
|
||||
@@ -10,6 +10,12 @@ export type PurchasingAddOnCode = {
|
||||
code: string
|
||||
}
|
||||
|
||||
type RecurlyCoupon = {
|
||||
code: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
type Recurly = {
|
||||
tax: number
|
||||
taxRate: number
|
||||
@@ -23,25 +29,10 @@ type Recurly = {
|
||||
currency: CurrencyCode
|
||||
state?: SubscriptionState
|
||||
trialEndsAtFormatted: Nullable<string>
|
||||
trial_ends_at: Nullable<string>
|
||||
activeCoupons: any[] // TODO: confirm type in array
|
||||
account: {
|
||||
email: string
|
||||
created_at: string
|
||||
// data via Recurly API
|
||||
has_canceled_subscription: {
|
||||
_: 'false' | 'true'
|
||||
$: {
|
||||
type: 'boolean'
|
||||
}
|
||||
}
|
||||
has_past_due_invoice: {
|
||||
_: 'false' | 'true'
|
||||
$: {
|
||||
type: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
trialEndsAt: Nullable<string>
|
||||
activeCoupons: RecurlyCoupon[]
|
||||
accountEmail: string
|
||||
hasPastDueInvoice: boolean
|
||||
displayPrice: string
|
||||
planOnlyDisplayPrice: string
|
||||
addOnDisplayPricesWithoutAdditionalLicense: Record<string, string>
|
||||
@@ -69,7 +60,7 @@ export type Subscription = {
|
||||
planCode: string
|
||||
recurlySubscription_id: string
|
||||
plan: Plan
|
||||
pendingPlan?: Plan
|
||||
pendingPlan?: PendingRecurlyPlan
|
||||
addOns?: AddOn[]
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,12 @@ export type AddOn = {
|
||||
|
||||
// add-ons directly accessed through recurly
|
||||
export type RecurlyAddOn = {
|
||||
add_on_code: string
|
||||
code: string
|
||||
name: string
|
||||
quantity: number
|
||||
unit_amount_in_cents: number
|
||||
displayPrice: string
|
||||
unitPrice: number
|
||||
amount?: number
|
||||
displayPrice?: string
|
||||
}
|
||||
|
||||
export type PendingRecurlyPlan = {
|
||||
|
||||
Reference in New Issue
Block a user