diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs index 90ecd51091..792850a1c1 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs @@ -20,7 +20,6 @@ import { SubtotalLimitExceededError, HasPastDueInvoiceError, } from './Errors.js' -import RecurlyClient from './RecurlyClient.js' /** * @import { Subscription } from "../../../../types/subscription/dashboard/subscription.js" @@ -138,13 +137,13 @@ async function _removeUserFromGroup( async function addSeatsToGroupSubscription(req, res) { try { const userId = SessionManager.getLoggedInUserId(req.session) - const { subscription, recurlySubscription, plan } = + const { subscription, paymentProviderSubscription, plan } = await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails( userId ) await SubscriptionGroupHandler.promises.ensureFlexibleLicensingEnabled(plan) await SubscriptionGroupHandler.promises.ensureSubscriptionHasNoPendingChanges( - recurlySubscription + paymentProviderSubscription ) await SubscriptionGroupHandler.promises.ensureSubscriptionIsActive( subscription @@ -162,15 +161,15 @@ async function addSeatsToGroupSubscription(req, res) { if (flexibleLicensingForManuallyBilledSubscriptionsVariant === 'enabled') { await SubscriptionGroupHandler.promises.checkBillingInfoExistence( - recurlySubscription, + paymentProviderSubscription, userId ) } else { await SubscriptionGroupHandler.promises.ensureSubscriptionCollectionMethodIsNotManual( - recurlySubscription + paymentProviderSubscription ) // Check if the user has missing billing details - await RecurlyClient.promises.getPaymentMethod(userId) + await Modules.promises.hooks.fire('getPaymentMethod', userId) } res.render('subscriptions/add-seats', { @@ -178,7 +177,8 @@ async function addSeatsToGroupSubscription(req, res) { groupName: subscription.teamName, totalLicenses: subscription.membersLimit, isProfessional: isProfessionalGroupPlan(subscription), - isCollectionMethodManual: recurlySubscription.isCollectionMethodManual, + isCollectionMethodManual: + paymentProviderSubscription.isCollectionMethodManual, }) } catch (error) { if (error instanceof MissingBillingInfoError) { @@ -300,15 +300,15 @@ async function submitForm(req, res) { const userEmail = await UserGetter.promises.getUserEmail(userId) const { adding, poNumber } = req.body - const { recurlySubscription } = + const { paymentProviderSubscription } = await SubscriptionGroupHandler.promises.getUsersGroupSubscriptionDetails( userId ) - if (recurlySubscription.isCollectionMethodManual) { + if (paymentProviderSubscription.isCollectionMethodManual) { await SubscriptionGroupHandler.promises.updateSubscriptionPaymentTerms( userId, - recurlySubscription, + paymentProviderSubscription, poNumber ) } diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js index d3cce35b32..bc179f8d23 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionGroupHandler.js @@ -9,7 +9,6 @@ const { Subscription } = require('../../models/Subscription') const { User } = require('../../models/User') const RecurlyClient = require('./RecurlyClient') const PlansLocator = require('./PlansLocator') -const SubscriptionHandler = require('./SubscriptionHandler') const TeamInvitesHandler = require('./TeamInvitesHandler') const GroupPlansData = require('./GroupPlansData') const Modules = require('../../infrastructure/Modules') @@ -86,22 +85,24 @@ async function ensureSubscriptionIsActive(subscription) { } async function ensureSubscriptionCollectionMethodIsNotManual( - recurlySubscription + paymentProviderSubscription ) { - if (recurlySubscription.isCollectionMethodManual) { + if (paymentProviderSubscription.isCollectionMethodManual) { throw new ManuallyCollectedError( 'This subscription is being collected manually', { - recurlySubscription_id: recurlySubscription.id, + subscription_id: paymentProviderSubscription.id, } ) } } -async function ensureSubscriptionHasNoPendingChanges(recurlySubscription) { - if (recurlySubscription.pendingChange) { +async function ensureSubscriptionHasNoPendingChanges( + paymentProviderSubscription +) { + if (paymentProviderSubscription.pendingChange) { throw new PendingChangeError('This subscription has a pending change', { - recurlySubscription_id: recurlySubscription.id, + subscription_id: paymentProviderSubscription.id, }) } } @@ -136,47 +137,50 @@ async function getUsersGroupSubscriptionDetails(userId) { const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode) - const recurlySubscription = await RecurlyClient.promises.getSubscription( - subscription.recurlySubscription_id + const response = await Modules.promises.hooks.fire( + 'getPaymentFromRecord', + subscription ) + const { subscription: paymentProviderSubscription } = response[0] + return { userId, subscription, - recurlySubscription, + paymentProviderSubscription, plan, } } -async function checkBillingInfoExistence(recurlySubscription, userId) { +async function checkBillingInfoExistence(paymentProviderSubscription, userId) { // Verify the billing info only if the collection method is not manual (e.g. automatic) - if (!recurlySubscription.isCollectionMethodManual) { + if (!paymentProviderSubscription.isCollectionMethodManual) { // Check if the user has missing billing details - await RecurlyClient.promises.getPaymentMethod(userId) + await Modules.promises.hooks.fire('getPaymentMethod', userId) } } async function _addSeatsSubscriptionChange(userId, adding) { - const { subscription, recurlySubscription, plan } = + const { subscription, paymentProviderSubscription, plan } = await getUsersGroupSubscriptionDetails(userId) await ensureFlexibleLicensingEnabled(plan) await ensureSubscriptionIsActive(subscription) - await ensureSubscriptionHasNoPendingChanges(recurlySubscription) - await checkBillingInfoExistence(recurlySubscription, userId) + await ensureSubscriptionHasNoPendingChanges(paymentProviderSubscription) + await checkBillingInfoExistence(paymentProviderSubscription, userId) await ensureSubscriptionHasNoPastDueInvoice(subscription) const currentAddonQuantity = - recurlySubscription.addOns.find( + paymentProviderSubscription.addOns.find( addOn => addOn.code === MEMBERS_LIMIT_ADD_ON_CODE )?.quantity ?? 0 // Keeps only the new total quantity of addon const nextAddonQuantity = currentAddonQuantity + adding let changeRequest - if (recurlySubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE)) { + if (paymentProviderSubscription.hasAddOn(MEMBERS_LIMIT_ADD_ON_CODE)) { // Not providing a custom price as once the subscription is locked // to an add-on at a given price, it will use it for subsequent payments - changeRequest = recurlySubscription.getRequestForAddOnUpdate( + changeRequest = paymentProviderSubscription.getRequestForAddOnUpdate( MEMBERS_LIMIT_ADD_ON_CODE, nextAddonQuantity ) @@ -185,7 +189,7 @@ async function _addSeatsSubscriptionChange(userId, adding) { const pattern = /^group_(collaborator|professional)_(2|3|4|5|10|20|50)_(educational|enterprise)$/ const [, planCode, size, usage] = plan.planCode.match(pattern) - const currency = recurlySubscription.currency + const currency = paymentProviderSubscription.currency const planPriceInCents = GroupPlansData[usage][planCode][currency][size].price_in_cents const legacyUnitPriceInCents = @@ -194,7 +198,7 @@ async function _addSeatsSubscriptionChange(userId, adding) { if ( _shouldUseLegacyPricing( - recurlySubscription.planPrice, + paymentProviderSubscription.planPrice, planPriceInCents / 100, usage, size @@ -203,7 +207,7 @@ async function _addSeatsSubscriptionChange(userId, adding) { unitPrice = legacyUnitPriceInCents / 100 } - changeRequest = recurlySubscription.getRequestForAddOnPurchase( + changeRequest = paymentProviderSubscription.getRequestForAddOnPurchase( MEMBERS_LIMIT_ADD_ON_CODE, nextAddonQuantity, unitPrice @@ -213,7 +217,7 @@ async function _addSeatsSubscriptionChange(userId, adding) { return { changeRequest, currentAddonQuantity, - recurlySubscription, + paymentProviderSubscription, } } @@ -237,37 +241,38 @@ function _shouldUseLegacyPricing( async function previewAddSeatsSubscriptionChange(userId, adding) { const { changeRequest, currentAddonQuantity } = await _addSeatsSubscriptionChange(userId, adding) - const subscriptionChange = - await RecurlyClient.promises.previewSubscriptionChange(changeRequest) - const subscriptionChangePreview = - await SubscriptionController.makeChangePreview( - { - type: 'add-on-update', - addOn: { - code: MEMBERS_LIMIT_ADD_ON_CODE, - quantity: subscriptionChange.nextAddOns.find( - addon => addon.code === MEMBERS_LIMIT_ADD_ON_CODE - ).quantity, - prevQuantity: currentAddonQuantity, - }, + const response = await Modules.promises.hooks.fire( + 'previewSubscriptionChangeRequest', + changeRequest + ) + const subscriptionChange = response[0] + const subscriptionChangePreview = SubscriptionController.makeChangePreview( + { + type: 'add-on-update', + addOn: { + code: MEMBERS_LIMIT_ADD_ON_CODE, + quantity: subscriptionChange.nextAddOns.find( + addon => addon.code === MEMBERS_LIMIT_ADD_ON_CODE + ).quantity, + prevQuantity: currentAddonQuantity, }, - subscriptionChange - ) + }, + subscriptionChange + ) return subscriptionChangePreview } async function createAddSeatsSubscriptionChange(userId, adding, poNumber) { - const { changeRequest, recurlySubscription } = + const { changeRequest, paymentProviderSubscription } = await _addSeatsSubscriptionChange(userId, adding) - if (recurlySubscription.isCollectionMethodManual) { - await updateSubscriptionPaymentTerms(userId, recurlySubscription, poNumber) + if (paymentProviderSubscription.isCollectionMethodManual) { + await updateSubscriptionPaymentTerms(paymentProviderSubscription, poNumber) } - - await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) - await SubscriptionHandler.promises.syncSubscription( - { uuid: recurlySubscription.id }, + await Modules.promises.hooks.fire( + 'applySubscriptionChangeRequestAndSync', + changeRequest, userId ) @@ -275,21 +280,30 @@ async function createAddSeatsSubscriptionChange(userId, adding, poNumber) { } async function updateSubscriptionPaymentTerms( - userId, - recurlySubscription, + paymentProviderSubscription, poNumber ) { + if (paymentProviderSubscription.service?.includes('stripe')) { + // TODO: Implement Stripe payment terms update + throw new OError( + 'Updating payment terms is not supported for Stripe subscriptions', + { + subscriptionId: paymentProviderSubscription.id, + } + ) + } + const [termsAndConditions] = await Modules.promises.hooks.fire( 'generateTermsAndConditions', - { currency: recurlySubscription.currency, poNumber } + { currency: paymentProviderSubscription.currency, poNumber } ) const updateRequest = poNumber - ? recurlySubscription.getRequestForPoNumberAndTermsAndConditionsUpdate( + ? paymentProviderSubscription.getRequestForPoNumberAndTermsAndConditionsUpdate( poNumber, termsAndConditions ) - : recurlySubscription.getRequestForTermsAndConditionsUpdate( + : paymentProviderSubscription.getRequestForTermsAndConditionsUpdate( termsAndConditions ) diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 104acd8783..656b57aa01 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -74,7 +74,7 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) { */ async function previewSubscriptionChange(userId, planCode) { const change = await Modules.promises.hooks.fire( - 'previewSubscriptionChange', + 'previewPlanChange', userId, planCode ) diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs index 74aea91b89..2b52f2be37 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupController.test.mjs @@ -55,7 +55,7 @@ describe('SubscriptionGroupController', function () { getUsersGroupSubscriptionDetails: sinon.stub().resolves({ subscription: ctx.subscription, plan: ctx.plan, - recurlySubscription: ctx.recurlySubscription, + paymentProviderSubscription: ctx.recurlySubscription, }), previewAddSeatsSubscriptionChange: sinon .stub() diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js index c29ab4a1c0..63a6e25fde 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionGroupHandlerTests.js @@ -193,21 +193,26 @@ describe('SubscriptionGroupHandler', function () { this.Modules = { promises: { hooks: { - fire: sinon.stub().callsFake(hookName => { - if (hookName === 'generateTermsAndConditions') { - return Promise.resolve(['T&Cs']) - } - if (hookName === 'getPaymentFromRecord') { - return Promise.resolve([ - { account: { hasPastDueInvoice: false } }, - ]) - } - return Promise.resolve() - }), + fire: sinon.stub(), }, }, } + this.Modules.promises.hooks.fire + .withArgs('generateTermsAndConditions') + .resolves(['T&Cs']) + .withArgs('getPaymentFromRecord') + .resolves([ + { + subscription: this.recurlySubscription, + account: { hasPastDueInvoice: false }, + }, + ]) + .withArgs('previewSubscriptionChangeRequest') + .resolves([this.previewSubscriptionChange]) + .withArgs('previewGroupPlanUpgrade') + .resolves([{ subscriptionChange: this.previewSubscriptionChange }]) + this.Handler = SandboxedModule.require(modulePath, { requires: { './SubscriptionUpdater': this.SubscriptionUpdater, @@ -389,7 +394,7 @@ describe('SubscriptionGroupHandler', function () { this.PaymentProviderEntities.MEMBERS_LIMIT_ADD_ON_CODE, canUseFlexibleLicensing: true, }, - recurlySubscription: this.recurlySubscription, + paymentProviderSubscription: this.recurlySubscription, }) }) }) @@ -441,11 +446,16 @@ describe('SubscriptionGroupHandler', function () { this.adminUser_id, this.adding ) - this.RecurlyClient.promises.getPaymentMethod - .calledWith(this.adminUser_id) + this.Modules.promises.hooks.fire + .calledWith('getPaymentFromRecord', { + groupPlan: true, + recurlyStatus: { + state: 'active', + }, + }) .should.equal(true) - this.RecurlyClient.promises.previewSubscriptionChange - .calledWith(this.changeRequest) + this.Modules.promises.hooks.fire + .calledWith('previewSubscriptionChangeRequest', this.changeRequest) .should.equal(true) this.SubscriptionController.makeChangePreview .calledWith( @@ -473,9 +483,14 @@ describe('SubscriptionGroupHandler', function () { return true }, } - this.RecurlyClient.promises.getSubscription = sinon - .stub() - .resolves(this.recurlySubscription) + this.Modules.promises.hooks.fire + .withArgs('getPaymentFromRecord') + .resolves([ + { + subscription: this.recurlySubscription, + account: { hasPastDueInvoice: false }, + }, + ]) const result = await this.Handler.promises.createAddSeatsSubscriptionChange( @@ -491,13 +506,10 @@ describe('SubscriptionGroupHandler', function () { .and(sinon.match.has('termsAndConditions')) ) .should.equal(true) - this.RecurlyClient.promises.applySubscriptionChangeRequest - .calledWith(this.changeRequest) - .should.equal(true) - this.SubscriptionHandler.promises.syncSubscription + this.Modules.promises.hooks.fire .calledWith( - { uuid: this.recurlySubscription.id }, - this.adminUser_id + 'applySubscriptionChangeRequestAndSync', + this.changeRequest ) .should.equal(true) expect(result).to.deep.equal({ @@ -511,7 +523,6 @@ describe('SubscriptionGroupHandler', function () { describe('accounts with PO number', function () { it('should update the subscription PO number and T&C', async function () { await this.Handler.promises.updateSubscriptionPaymentTerms( - this.adminUser_id, this.recurlySubscription, this.poNumberAndTermsAndConditionsUpdate.poNumber ) @@ -525,12 +536,23 @@ describe('SubscriptionGroupHandler', function () { .calledWith(this.poNumberAndTermsAndConditionsUpdate) .should.equal(true) }) + + it('should fail for stripe', async function () { + this.recurlySubscription.service = 'stripe' + await expect( + this.Handler.promises.updateSubscriptionPaymentTerms( + this.recurlySubscription, + this.poNumberAndTermsAndConditionsUpdate.poNumber + ) + ).to.be.rejectedWith( + 'Updating payment terms is not supported for Stripe subscriptions' + ) + }) }) describe('accounts with no PO number', function () { it('should update the subscription T&C only', async function () { await this.Handler.promises.updateSubscriptionPaymentTerms( - this.adminUser_id, this.recurlySubscription ) this.recurlySubscription.getRequestForTermsAndConditionsUpdate @@ -570,11 +592,16 @@ describe('SubscriptionGroupHandler', function () { let preview afterEach(function () { - this.RecurlyClient.promises.getPaymentMethod - .calledWith(this.adminUser_id) + this.Modules.promises.hooks.fire + .calledWith('getPaymentFromRecord', { + groupPlan: true, + recurlyStatus: { + state: 'active', + }, + }) .should.equal(true) - this.RecurlyClient.promises.previewSubscriptionChange - .calledWith(this.changeRequest) + this.Modules.promises.hooks.fire + .calledWith('previewSubscriptionChangeRequest', this.changeRequest) .should.equal(true) this.SubscriptionController.makeChangePreview .calledWith( @@ -781,9 +808,6 @@ describe('SubscriptionGroupHandler', function () { describe('getGroupPlanUpgradePreview', function () { it('should generate preview for subscription upgrade', async function () { - this.Modules.promises.hooks.fire.resolves([ - { subscriptionChange: this.previewSubscriptionChange }, - ]) const result = await this.Handler.promises.getGroupPlanUpgradePreview( this.user_id ) @@ -797,8 +821,8 @@ describe('SubscriptionGroupHandler', function () { this.recurlySubscription, this.adminUser_id ) - this.RecurlyClient.promises.getPaymentMethod - .calledWith(this.adminUser_id) + this.Modules.promises.hooks.fire + .calledWith('getPaymentMethod', this.adminUser_id) .should.equal(true) })