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:
Kristina
2025-04-10 08:05:06 +00:00
committed by Copybot
parent 28468e134c
commit 7920cd9d3d
26 changed files with 840 additions and 417 deletions
@@ -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)
}
@@ -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
@@ -75,8 +75,7 @@ function PersonalSubscription() {
return (
<>
{personalSubscription.recurly.account.has_past_due_invoice._ ===
'true' && (
{personalSubscription.recurly.hasPastDueInvoice && (
<PastDueSubscriptionAlert subscription={personalSubscription} />
)}
<PersonalSubscriptionStates
@@ -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 && (
@@ -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}
@@ -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]}
@@ -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
)
@@ -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' &&
@@ -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"
@@ -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 &&
@@ -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
@@ -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>
))}
@@ -37,7 +37,7 @@ function SuccessfulSubscription() {
type="success"
content={
<>
{subscription.recurly.trial_ends_at && (
{subscription.recurly.trialEndsAt && (
<>
<p>
<Trans
@@ -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}`
)
@@ -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[]
}
+5 -3
View File
@@ -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 = {