From 7920cd9d3d40c055acad18ea0f18c4360b319161 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Wed, 9 Apr 2025 12:05:36 +0200 Subject: [PATCH] Merge pull request #24519 from overleaf/kh-add-payment-service-low-delta [web] add `PaymentService` to `buildUsersSubscriptionViewModel` GitOrigin-RevId: 543531226bad38d34b225dae28cf00a5e02e5558 --- .../Features/Subscription/PaymentService.js | 81 ++++ .../Features/Subscription/RecurlyClient.js | 85 +++- .../Features/Subscription/RecurlyEntities.js | 46 ++ .../Features/Subscription/RecurlyWrapper.js | 22 - .../SubscriptionViewModelBuilder.js | 148 +++--- .../components/dashboard/pause-modal.tsx | 4 +- ...rsonal-subscription-recurly-sync-email.tsx | 2 +- .../dashboard/personal-subscription.tsx | 3 +- .../dashboard/states/active/active-new.tsx | 13 +- .../dashboard/states/active/active.tsx | 4 +- .../dashboard/states/active/add-ons.tsx | 2 +- .../cancel-plan/cancel-subscription.tsx | 2 +- .../active/cancel-subscription-button.tsx | 4 +- .../change-plan/change-to-group-plan.tsx | 4 +- .../states/active/pending-plan-change.tsx | 2 +- .../states/active/subscription-remainder.tsx | 4 +- .../components/shared/price-exceptions.tsx | 2 +- .../successful-subscription.tsx | 2 +- .../dashboard/personal-subscription.test.tsx | 2 +- .../dashboard/states/active/active.test.tsx | 4 +- .../subscription/fixtures/subscriptions.ts | 140 ++---- .../src/Subscription/PaymentServiceTests.js | 117 +++++ .../src/Subscription/RecurlyClientTests.js | 81 +++- .../SubscriptionViewModelBuilderTests.js | 442 ++++++++++++------ .../subscription/dashboard/subscription.ts | 33 +- services/web/types/subscription/plan.ts | 8 +- 26 files changed, 840 insertions(+), 417 deletions(-) create mode 100644 services/web/app/src/Features/Subscription/PaymentService.js create mode 100644 services/web/test/unit/src/Subscription/PaymentServiceTests.js diff --git a/services/web/app/src/Features/Subscription/PaymentService.js b/services/web/app/src/Features/Subscription/PaymentService.js new file mode 100644 index 0000000000..6f8a12e0a0 --- /dev/null +++ b/services/web/app/src/Features/Subscription/PaymentService.js @@ -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} + */ +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, + }, +} diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index 7bd7d1f3b2..91416ddfdd 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -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} + */ 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} + */ +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, diff --git a/services/web/app/src/Features/Subscription/RecurlyEntities.js b/services/web/app/src/Features/Subscription/RecurlyEntities.js index 5c6f700b5f..9c5eaaeac6 100644 --- a/services/web/app/src/Features/Subscription/RecurlyEntities.js +++ b/services/web/app/src/Features/Subscription/RecurlyEntities.js @@ -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, } diff --git a/services/web/app/src/Features/Subscription/RecurlyWrapper.js b/services/web/app/src/Features/Subscription/RecurlyWrapper.js index 053c983502..2227597737 100644 --- a/services/web/app/src/Features/Subscription/RecurlyWrapper.js +++ b/services/web/app/src/Features/Subscription/RecurlyWrapper.js @@ -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), diff --git a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js index aa3d577991..309678cb77 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js +++ b/services/web/app/src/Features/Subscription/SubscriptionViewModelBuilder.js @@ -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) } diff --git a/services/web/frontend/js/features/subscription/components/dashboard/pause-modal.tsx b/services/web/frontend/js/features/subscription/components/dashboard/pause-modal.tsx index 5d767d5fb6..cba3ecd9fd 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/pause-modal.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/pause-modal.tsx @@ -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) } diff --git a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx index 9c08e175d6..c69334cccd 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription-recurly-sync-email.tsx @@ -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 diff --git a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx index 7f56daf750..50c438f9d9 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/personal-subscription.tsx @@ -75,8 +75,7 @@ function PersonalSubscription() { return ( <> - {personalSubscription.recurly.account.has_past_due_invoice._ === - 'true' && ( + {personalSubscription.recurly.hasPastDueInvoice && ( )} {t('want_change_to_apply_before_plan_end')}

)} - {isInFreeTrial(subscription.recurly.trial_ends_at) && + {isInFreeTrial(subscription.recurly.trialEndsAt) && subscription.recurly.trialEndsAtFormatted && ( ) : ( <> - {!hasPendingPause && - subscription.recurly.account.has_past_due_invoice._ !== 'true' && ( - - {t('change_plan')} - - )} + {!hasPendingPause && !subscription.recurly.hasPastDueInvoice && ( + + {t('change_plan')} + + )} )} {hasPendingPause && ( diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx index edc9fbfc33..d7c9f509a5 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx @@ -108,7 +108,7 @@ export function ActiveSubscription({ {!recurlyLoadError && !subscription.groupPlan && !hasPendingPause && - subscription.recurly.account.has_past_due_invoice._ !== 'true' && ( + !subscription.recurly.hasPastDueInvoice && ( <> {' '} } - {isInFreeTrial(subscription.recurly.trial_ends_at) && + {isInFreeTrial(subscription.recurly.trialEndsAt) && subscription.recurly.trialEndsAtFormatted && ( pendingAddOn.addOnCode !== addOn.addOnCode + pendingAddOn => pendingAddOn.code !== addOn.addOnCode ) } displayPrice={addOnsDisplayPrices[addOn.addOnCode]} diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx index eddb4baafc..d5f8b5408e 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx @@ -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 ) diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx index a99f6e5762..cf4fc39690 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-subscription-button.tsx @@ -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' && diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx index 2b0b153cae..08034b103b 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/change-to-group-plan.tsx @@ -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() {

{t('looking_multiple_licenses')}

{t('reduce_costs_group_licenses')}


- {isInFreeTrial(subscription?.recurly?.trial_ends_at) ? ( + {isInFreeTrial(subscription?.recurly?.trialEndsAt) ? ( <> addOn.add_on_code === AI_ADD_ON_CODE) + !pendingPlan.addOns?.some(addOn => addOn.code === AI_ADD_ON_CODE) const pendingAdditionalLicenses = (subscription.recurly.pendingAdditionalLicenses && diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx index 16544e1507..bba4cb5c44 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/subscription-remainder.tsx @@ -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 diff --git a/services/web/frontend/js/features/subscription/components/shared/price-exceptions.tsx b/services/web/frontend/js/features/subscription/components/shared/price-exceptions.tsx index b8cec5b8d1..a50e110726 100644 --- a/services/web/frontend/js/features/subscription/components/shared/price-exceptions.tsx +++ b/services/web/frontend/js/features/subscription/components/shared/price-exceptions.tsx @@ -19,7 +19,7 @@ export function PriceExceptions({ subscription }: PriceExceptionsProps) { * {t('coupons_not_included')}:
    {activeCoupons.map(coupon => ( -
  • +
  • {coupon.description || coupon.name}
  • ))} diff --git a/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx b/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx index 3c64846db0..3a116f9324 100644 --- a/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx +++ b/services/web/frontend/js/features/subscription/components/successful-subscription/successful-subscription.tsx @@ -37,7 +37,7 @@ function SuccessfulSubscription() { type="success" content={ <> - {subscription.recurly.trial_ends_at && ( + {subscription.recurly.trialEndsAt && ( <>

    ', 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}` ) diff --git a/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx index f33886215f..34e6d28c3a 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx @@ -110,7 +110,7 @@ describe('', 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('', function () { subscriptionWithActiveCoupons.recurly.activeCoupons = [ { name: 'fake coupon name', + code: 'fake-coupon', + description: '', }, ] renderActiveSubscription(subscriptionWithActiveCoupons) diff --git a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts index 24194ea080..4c9fe5f51d 100644 --- a/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts +++ b/services/web/test/frontend/features/subscription/fixtures/subscriptions.ts @@ -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: [], diff --git a/services/web/test/unit/src/Subscription/PaymentServiceTests.js b/services/web/test/unit/src/Subscription/PaymentServiceTests.js new file mode 100644 index 0000000000..d836d01bef --- /dev/null +++ b/services/web/test/unit/src/Subscription/PaymentServiceTests.js @@ -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) + }) + }) +}) diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 91c16b1ac1..36ef8a5b01 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -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 diff --git a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js index c1075f1a60..f72b42c7c9 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionViewModelBuilderTests.js @@ -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, }) }) }) diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index c67f249c4c..d0cd229601 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -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 - trial_ends_at: Nullable - 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 + activeCoupons: RecurlyCoupon[] + accountEmail: string + hasPastDueInvoice: boolean displayPrice: string planOnlyDisplayPrice: string addOnDisplayPricesWithoutAdditionalLicense: Record @@ -69,7 +60,7 @@ export type Subscription = { planCode: string recurlySubscription_id: string plan: Plan - pendingPlan?: Plan + pendingPlan?: PendingRecurlyPlan addOns?: AddOn[] } diff --git a/services/web/types/subscription/plan.ts b/services/web/types/subscription/plan.ts index 8747663575..820dc8f341 100644 --- a/services/web/types/subscription/plan.ts +++ b/services/web/types/subscription/plan.ts @@ -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 = {