From 793506c10f03ece43479be79d076988d9595dbc1 Mon Sep 17 00:00:00 2001 From: Jimmy Domagala-Tang Date: Mon, 16 Feb 2026 12:18:19 -0500 Subject: [PATCH] feat: removing recurly revert logic since we are sunsetting recurly (#31470) GitOrigin-RevId: c165c7d14ec5d57fdbe3b0962bd65222182a9f48 --- .../web/app/src/Features/Errors/Errors.js | 3 - .../Subscription/PaymentProviderEntities.mjs | 37 ----- .../Features/Subscription/RecurlyClient.mjs | 36 ----- .../Subscription/SubscriptionController.mjs | 50 +----- .../Subscription/SubscriptionHandler.mjs | 83 ---------- .../Subscription/SubscriptionLocator.mjs | 25 --- .../Subscription/SubscriptionUpdater.mjs | 54 ------- services/web/app/src/models/Subscription.mjs | 7 - .../PaymentProviderEntities.test.mjs | 69 -------- .../src/Subscription/RecurlyClient.test.mjs | 47 ------ .../SubscriptionController.test.mjs | 143 ----------------- .../Subscription/SubscriptionHandler.test.mjs | 149 ------------------ .../Subscription/SubscriptionUpdater.test.mjs | 93 ----------- 13 files changed, 1 insertion(+), 795 deletions(-) diff --git a/services/web/app/src/Features/Errors/Errors.js b/services/web/app/src/Features/Errors/Errors.js index 175d0af16e..934a70a9fc 100644 --- a/services/web/app/src/Features/Errors/Errors.js +++ b/services/web/app/src/Features/Errors/Errors.js @@ -47,8 +47,6 @@ class DuplicateNameError extends OError {} class InvalidNameError extends BackwardCompatibleError {} -class IndeterminateInvoiceError extends OError {} - class UnsupportedFileTypeError extends BackwardCompatibleError {} class FileTooLargeError extends BackwardCompatibleError {} @@ -378,7 +376,6 @@ module.exports = { UnconfirmedEmailError, EmailExistsError, InvalidError, - IndeterminateInvoiceError, NotInV2Error, OutputFileFetchFailedError, SAMLAssertionAudienceMismatch, diff --git a/services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs b/services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs index ea9857c9e1..52d25b5018 100644 --- a/services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs +++ b/services/web/app/src/Features/Subscription/PaymentProviderEntities.mjs @@ -394,43 +394,6 @@ export class PaymentProviderSubscription { }) } - /** - * Form a request to revert the plan to it's last saved backup state - * - * @param {string} previousPlanCode - * @param {Array | null} previousAddOns - * @return {PaymentProviderSubscriptionChangeRequest} - * - * @throws {OError} if the restore point plan doesnt exist - */ - getRequestForPlanRevert(previousPlanCode, previousAddOns) { - const lastSuccessfulPlan = - PlansLocator.findLocalPlanInSettings(previousPlanCode) - if (lastSuccessfulPlan == null) { - throw new OError('Unable to find plan in settings', { previousPlanCode }) - } - const changeRequest = new PaymentProviderSubscriptionChangeRequest({ - subscription: this, - timeframe: 'now', - planCode: previousPlanCode, - }) - - // defaulting to empty array is important, as that will wipe away any add-ons that were added in the failed payment - // but were not part of the last successful subscription - const addOns = [] - for (const previousAddon of previousAddOns || []) { - const addOnUpdate = new PaymentProviderSubscriptionAddOnUpdate({ - code: previousAddon.addOnCode, - quantity: previousAddon.quantity, - unitPrice: previousAddon.unitAmountInCents / 100, - }) - addOns.push(addOnUpdate) - } - changeRequest.addOnUpdates = addOns - - return changeRequest - } - /** * Upgrade group plan with the plan code provided * diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.mjs b/services/web/app/src/Features/Subscription/RecurlyClient.mjs index 5b50be27a4..ab1b9f6dea 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.mjs +++ b/services/web/app/src/Features/Subscription/RecurlyClient.mjs @@ -884,38 +884,6 @@ function subscriptionUpdateRequestToApi(updateRequest) { return requestBody } -/** - * Retrieves a list of failed invoices for a given Recurly subscription ID. - * - * @async - * @function - * @param {string} subscriptionId - The ID of the Recurly subscription to fetch failed invoices for. - * @returns {Promise>} A promise that resolves to an array of failed invoice objects. - */ -async function getPastDueInvoices(subscriptionId) { - const failed = [] - const invoices = client.listSubscriptionInvoices(`uuid-${subscriptionId}`, { - params: { state: 'past_due' }, - }) - - for await (const invoice of invoices.each()) { - failed.push(invoice) - } - return failed -} - -/** - * Marks an invoice as failed using the Recurly client. - * - * @async - * @function failInvoice - * @param {string} invoiceId - The ID of the invoice to be marked as failed. - * @returns {Promise} Resolves when the invoice has been successfully marked as failed. - */ -async function failInvoice(invoiceId) { - await client.markInvoiceFailed(invoiceId) -} - async function terminateSubscriptionByUuid(subscriptionUuid) { const subscription = await client.terminateSubscription( 'uuid-' + subscriptionUuid, @@ -952,8 +920,6 @@ export default { subscriptionIsCanceledOrExpired, pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid), resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid), - getPastDueInvoices: callbackify(getPastDueInvoices), - failInvoice: callbackify(failInvoice), terminateSubscriptionByUuid: callbackify(terminateSubscriptionByUuid), promises: { @@ -975,8 +941,6 @@ export default { getPaymentMethod, getAddOn, getPlan, - getPastDueInvoices, - failInvoice, terminateSubscriptionByUuid, }, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.mjs b/services/web/app/src/Features/Subscription/SubscriptionController.mjs index 2f4fa3a9ec..7634e712e8 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionController.mjs @@ -33,7 +33,6 @@ import UserGetter from '../User/UserGetter.mjs' import PermissionsManager from '../Authorization/PermissionsManager.mjs' import { sanitizeSessionUserForFrontEnd } from '../../infrastructure/FrontEndUser.mjs' import { z, parseReq } from '../../infrastructure/Validation.mjs' -import { IndeterminateInvoiceError } from '../Errors/Errors.js' import SubscriptionLocator from './SubscriptionLocator.mjs' import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities.mjs' @@ -887,54 +886,7 @@ function recurlyCallback(req, res, next) { ) ) - // this is a recurly only case which is required since Recurly does not have a reliable way to check credit info pre-upgrade purchase - if (event === 'failed_payment_notification') { - if (!Settings.planReverts?.enabled) { - return res.sendStatus(200) - } - - // A manual charge may have no subscription, in which case we get a - // element, which produces an object instead - // of a string subscription_id. - const subscriptionId = eventData.transaction?.subscription_id - if (!subscriptionId || typeof subscriptionId !== 'string') { - logger.info( - { transactionId: eventData.transaction?.id }, - 'ignoring failed_payment_notification without subscription_id' - ) - return res.sendStatus(200) - } - - SubscriptionHandler.getSubscriptionRestorePoint( - subscriptionId, - function (err, lastSubscription) { - if (err) { - return next(err) - } - // if theres no restore point it could be a failed renewal, or no restore set. Either way it will be handled through dunning automatically - if (!lastSubscription || !lastSubscription?.planCode) { - return res.sendStatus(200) - } - SubscriptionHandler.revertPlanChange( - eventData.transaction.subscription_id, - lastSubscription, - function (err) { - if (err instanceof IndeterminateInvoiceError) { - logger.warn( - { recurlySubscriptionId: err.info.recurlySubscriptionId }, - 'could not determine invoice to fail for subscription' - ) - return res.sendStatus(200) - } - if (err) { - return next(err) - } - return res.sendStatus(200) - } - ) - } - ) - } else if ( + if ( [ 'new_subscription_notification', 'updated_subscription_notification', diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs b/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs index 1e5fcde5c7..8bc5af2082 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.mjs @@ -2,17 +2,14 @@ import RecurlyWrapper from './RecurlyWrapper.mjs' -import RecurlyClient from './RecurlyClient.mjs' import { User } from '../../models/User.mjs' import logger from '@overleaf/logger' import SubscriptionHelper from './SubscriptionHelper.mjs' import SubscriptionUpdater from './SubscriptionUpdater.mjs' -import SubscriptionLocator from './SubscriptionLocator.mjs' import LimitationsManager from './LimitationsManager.mjs' import EmailHandler from '../Email/EmailHandler.mjs' import { callbackify } from '@overleaf/promise-utils' import UserUpdater from '../User/UserUpdater.mjs' -import { IndeterminateInvoiceError } from '../Errors/Errors.js' import Modules from '../../infrastructure/Modules.mjs' import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs' import { AI_ADD_ON_CODE } from './AiHelper.mjs' @@ -399,80 +396,6 @@ async function resumeSubscription(user) { await Modules.promises.hooks.fire('resumePaidSubscription', subscription) } -/** - * @param recurlySubscriptionId - */ -async function getSubscriptionRestorePoint(recurlySubscriptionId) { - const lastSubscription = - await SubscriptionLocator.promises.getLastSuccessfulSubscription( - recurlySubscriptionId - ) - return lastSubscription -} - -/** - * @param recurlySubscriptionId - * @param subscriptionRestorePoint - */ -async function revertPlanChange( - recurlySubscriptionId, - subscriptionRestorePoint -) { - const subscription = await RecurlyClient.promises.getSubscription( - recurlySubscriptionId - ) - - const changeRequest = subscription.getRequestForPlanRevert( - subscriptionRestorePoint.planCode, - subscriptionRestorePoint.addOns - ) - - const pastDue = await RecurlyClient.promises.getPastDueInvoices( - recurlySubscriptionId - ) - - // only process revert requests within the past 24 hours, as we dont want to restore plans at the end of their dunning cycle - const yesterday = new Date() - yesterday.setDate(yesterday.getDate() - 1) - if ( - pastDue.length !== 1 || - !pastDue[0].id || - !pastDue[0].dueAt || - pastDue[0].dueAt < yesterday || - pastDue[0].collectionMethod !== 'automatic' - ) { - throw new IndeterminateInvoiceError( - 'cant determine invoice to fail for plan revert', - { - recurlySubscriptionId, - } - ) - } - - await RecurlyClient.promises.failInvoice(pastDue[0].id) - await SubscriptionUpdater.promises.setSubscriptionWasReverted( - subscriptionRestorePoint._id - ) - await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest) - await syncSubscription({ uuid: recurlySubscriptionId }, {}) -} - -async function setSubscriptionRestorePoint(userId) { - const subscription = - await SubscriptionLocator.promises.getUsersSubscription(userId) - // if the subscription is not a recurly one, we can return early as we dont allow for failed payments on other payment providers - // we need to deal with it for recurly, because we cant verify payment in advance - if (!subscription?.recurlySubscription_id || !subscription.planCode) { - return - } - await SubscriptionUpdater.promises.setRestorePoint( - subscription.id, - subscription.planCode, - subscription.addOns, - false - ) -} - export default { validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly), createSubscription: callbackify(createSubscription), @@ -490,9 +413,6 @@ export default { reactivateAddon: callbackify(reactivateAddon), pauseSubscription: callbackify(pauseSubscription), resumeSubscription: callbackify(resumeSubscription), - revertPlanChange: callbackify(revertPlanChange), - setSubscriptionRestorePoint: callbackify(setSubscriptionRestorePoint), - getSubscriptionRestorePoint: callbackify(getSubscriptionRestorePoint), promises: { validateNoSubscriptionInRecurly, createSubscription, @@ -510,8 +430,5 @@ export default { reactivateAddon, pauseSubscription, resumeSubscription, - revertPlanChange, - setSubscriptionRestorePoint, - getSubscriptionRestorePoint, }, } diff --git a/services/web/app/src/Features/Subscription/SubscriptionLocator.mjs b/services/web/app/src/Features/Subscription/SubscriptionLocator.mjs index 9950162955..ace3a7beae 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionLocator.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionLocator.mjs @@ -1,7 +1,3 @@ -/** - * @import { AddOn } from '../../../../types/subscription/plan' - */ - import { callbackifyAll } from '@overleaf/promise-utils' import { Subscription } from '../../models/Subscription.mjs' @@ -183,27 +179,6 @@ const SubscriptionLocator = { } }, - /** - * Retrieves the last successful subscription for a given user. - * - * @async - * @function - * @param {string} recurlyId - The ID of the recurly subscription tied to the mongo subscription to check for a previous successful state. - * @returns {Promise<{_id: ObjectId, planCode: string, addOns: [AddOn]}|null>} A promise that resolves to the last successful planCode and addon state, - * or null if we havent stored a previous - */ - async getLastSuccessfulSubscription(recurlyId) { - const subscription = await Subscription.findOne({ - recurlySubscription_id: recurlyId, - }).exec() - return subscription && subscription.lastSuccesfulSubscription - ? { - ...subscription.lastSuccesfulSubscription, - _id: subscription._id, - } - : null - }, - async getUserSubscriptionStatus(userId) { let usersSubscription = { personal: false, group: false } diff --git a/services/web/app/src/Features/Subscription/SubscriptionUpdater.mjs b/services/web/app/src/Features/Subscription/SubscriptionUpdater.mjs index 802c669999..e2fa31ee32 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionUpdater.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionUpdater.mjs @@ -20,7 +20,6 @@ import Modules from '../../infrastructure/Modules.mjs' * @typedef {import('../../../../types/subscription/dashboard/subscription').Subscription} Subscription * @typedef {import('../../../../types/subscription/dashboard/subscription').PaymentProvider} PaymentProvider * @typedef {import('../../../../types/group-management/group-audit-log').GroupAuditLog} GroupAuditLog - * @import { AddOn } from '../../../../types/subscription/plan' * @typedef {InstanceType} MongoSubscription */ @@ -518,28 +517,6 @@ async function _sendSubscriptionEventForAllMembers(subscriptionId, event) { } } -/** - * Sets the plan code and addon state to revert the plan to in case of failed upgrades, or clears the last restore point if it was used/ voided - * @param {ObjectId} subscriptionId the mongo ID of the subscription to set the restore point for - * @param {string} planCode the plan code to revert to - * @param {Array} addOns the addOns to revert to - * @param {Boolean} consumed whether the restore point was used to revert a subscription - */ -async function setRestorePoint(subscriptionId, planCode, addOns, consumed) { - const update = { - $set: { - 'lastSuccesfulSubscription.planCode': planCode, - 'lastSuccesfulSubscription.addOns': addOns, - }, - } - - if (consumed) { - update.$inc = { timesRevertedDueToFailedPayment: 1 } - } - - await Subscription.updateOne({ _id: subscriptionId }, update).exec() -} - /** * Change the ownershiop of the given subscription. * @param {MongoSubscription} subscription @@ -572,31 +549,6 @@ async function transferSubscriptionOwnership( await Subscription.updateOne(query, update).exec() } -/** - * Clears the restore point for a given subscription, and signals that the subscription was sucessfully reverted. - * - * @async - * @function setSubscriptionWasReverted - * @param {ObjectId} subscriptionId the mongo ID of the subscription to set the restore point for - * @returns {Promise} Resolves when the restore point has been cleared. - */ -async function setSubscriptionWasReverted(subscriptionId) { - // consume the backup and flag that the subscription was reverted due to failed payment - await setRestorePoint(subscriptionId, null, null, true) -} - -/** - * Clears the restore point for a given subscription, and signals that the subscription was not reverted. - * - * @async - * @function voidRestorePoint - * @param {string} subscriptionId - The unique identifier of the subscription. - * @returns {Promise} Resolves when the restore point has been cleared. - */ -async function voidRestorePoint(subscriptionId) { - await setRestorePoint(subscriptionId, null, null, false) -} - export default { updateAdmin: callbackify(updateAdmin), syncSubscription: callbackify(syncSubscription), @@ -611,9 +563,6 @@ export default { restoreSubscription: callbackify(restoreSubscription), updateSubscriptionFromRecurly: callbackify(updateSubscriptionFromRecurly), scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures), - setSubscriptionRestorePoint: callbackify(setRestorePoint), - setSubscriptionWasReverted: callbackify(setSubscriptionWasReverted), - voidRestorePoint: callbackify(voidRestorePoint), promises: { updateAdmin, syncSubscription, @@ -628,9 +577,6 @@ export default { restoreSubscription, updateSubscriptionFromRecurly, scheduleRefreshFeatures, - setRestorePoint, - setSubscriptionWasReverted, - voidRestorePoint, handleExpiredSubscription, transferSubscriptionOwnership, }, diff --git a/services/web/app/src/models/Subscription.mjs b/services/web/app/src/models/Subscription.mjs index 602edfb1bd..9a84d9ff69 100644 --- a/services/web/app/src/models/Subscription.mjs +++ b/services/web/app/src/models/Subscription.mjs @@ -50,13 +50,6 @@ export const SubscriptionSchema = new Schema( invited_emails: [String], teamInvites: [TeamInviteSchema], recurlySubscription_id: String, - lastSuccesfulSubscription: { - planCode: { - type: String, - }, - addOns: Schema.Types.Mixed, - }, - timesRevertedDueToFailedPayment: { type: Number, default: 0 }, teamName: { type: String }, teamNotice: { type: String }, planCode: { type: String }, diff --git a/services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs b/services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs index ef13aab412..e14044f59b 100644 --- a/services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs +++ b/services/web/test/unit/src/Subscription/PaymentProviderEntities.test.mjs @@ -859,75 +859,6 @@ describe('PaymentProviderEntities', function () { ).to.throw(Errors.AddOnNotPresentError) }) }) - - describe('getRequestForPlanRevert()', function () { - beforeEach(function (ctx) { - const { PaymentProviderSubscription } = ctx.PaymentProviderEntities - ctx.subscription = new PaymentProviderSubscription({ - id: 'subscription-id', - userId: 'user-id', - planCode: 'regular-plan', - planName: 'My Plan', - planPrice: 10, - addOns: [ - { - addOnCode: 'addon-1', - quantity: 2, - unitAmountInCents: 500, - }, - { - addOnCode: 'addon-2', - quantity: 1, - unitAmountInCents: 600, - }, - ], - subtotal: 10.99, - taxRate: 0.2, - taxAmount: 2.4, - total: 14.4, - currency: 'USD', - }) - }) - - it('throws if the plan to revert to doesnt exist', function (ctx) { - const invalidPlanCode = 'non-existent-plan' - expect(() => - ctx.subscription.getRequestForPlanRevert(invalidPlanCode, null) - ).to.throw('Unable to find plan in settings') - }) - - it('creates a change request with the restore point', function (ctx) { - const previousPlanCode = 'cheap-plan' - const previousAddOns = [ - { addOnCode: 'addon-1', quantity: 1, unitAmountInCents: 500 }, - ] - const changeRequest = ctx.subscription.getRequestForPlanRevert( - previousPlanCode, - previousAddOns - ) - expect(changeRequest).to.be.an.instanceOf( - ctx.PaymentProviderEntities - .PaymentProviderSubscriptionChangeRequest - ) - expect(changeRequest.planCode).to.equal(previousPlanCode) - expect(changeRequest.addOnUpdates).to.deep.equal([ - { - code: 'addon-1', - quantity: 1, - unitPrice: 5, - }, - ]) - }) - - it('defaults to addons to an empty array to clear the addon state', function (ctx) { - const previousPlanCode = 'cheap-plan' - const changeRequest = ctx.subscription.getRequestForPlanRevert( - previousPlanCode, - null - ) - expect(changeRequest.addOnUpdates).to.deep.equal([]) - }) - }) }) }) diff --git a/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs b/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs index 121404a918..08e425792c 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs +++ b/services/web/test/unit/src/Subscription/RecurlyClient.test.mjs @@ -979,51 +979,4 @@ describe('RecurlyClient', function () { ) }) }) - - describe('getPastDueInvoices', function () { - beforeEach(function (ctx) { - ctx.client.listSubscriptionInvoices = sinon.stub() - }) - - it('should return empty if no past due are found', async function (ctx) { - ctx.client.listSubscriptionInvoices.returns({ - each: async function* () {}, - }) - const invoices = await ctx.RecurlyClient.promises.getPastDueInvoices( - ctx.subscription.id - ) - expect(invoices).to.deep.equal([]) - }) - - it('should return past due invoice', async function (ctx) { - const pastDueInvoice = { id: 'invoice-1', state: 'past_due' } - ctx.client.listSubscriptionInvoices.returns({ - each: async function* () { - yield pastDueInvoice - }, - }) - const invoices = await ctx.RecurlyClient.promises.getPastDueInvoices( - ctx.subscription.id - ) - expect(invoices).to.deep.equal([pastDueInvoice]) - }) - - it('should return multiple invoices if multiple past due exist', async function (ctx) { - const pastDueInvoices = [ - { id: 'invoice-1', state: 'past_due' }, - { id: 'invoice-2', state: 'past_due' }, - ] - ctx.client.listSubscriptionInvoices.returns({ - each: async function* () { - for (const invoice of pastDueInvoices) { - yield invoice - } - }, - }) - const invoices = await ctx.RecurlyClient.promises.getPastDueInvoices( - ctx.subscription.id - ) - expect(invoices).to.deep.equal(pastDueInvoices) - }) - }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs index ecd56ff630..1d150dd56c 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs @@ -57,7 +57,6 @@ describe('SubscriptionController', function () { syncSubscription: sinon.stub().yields(), attemptPaypalInvoiceCollection: sinon.stub().yields(), startFreeTrial: sinon.stub(), - revertPlanChange: sinon.stub(), promises: { createSubscription: sinon.stub().resolves(), updateSubscription: sinon.stub().resolves(), @@ -85,7 +84,6 @@ describe('SubscriptionController', function () { tax: 0, total: 2000, }), - revertPlanChange: sinon.stub().resolves(), }, } @@ -133,9 +131,6 @@ describe('SubscriptionController', function () { subdomain: 'sl', }, }, - planReverts: { - enabled: false, - }, siteUrl: 'http://de.overleaf.dev:3000', } ctx.AuthorizationManager = { @@ -869,144 +864,6 @@ describe('SubscriptionController', function () { ctx.res.sendStatus.calledWith(200) }) }) - - describe('with a failed payment notification', function () { - describe('with planReverts disabled in settings', function () { - beforeEach(async function (ctx) { - await new Promise(resolve => { - ctx.settings.planReverts = { enabled: false } - ctx.SubscriptionHandler.revertPlanChange = sinon.stub() - - ctx.req.body = { - failed_payment_notification: { - transaction: { - subscription_id: 'subscription-123', - }, - }, - } - - ctx.res = { - sendStatus() { - resolve() - }, - } - sinon.spy(ctx.res, 'sendStatus') - ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res) - }) - }) - it('should not call revertPlanChange', function (ctx) { - expect(ctx.SubscriptionHandler.revertPlanChange.called).to.be.false - }) - - it('should respond with 200', async function (ctx) { - await new Promise(resolve => { - ctx.res.sendStatus.calledWith(200) - resolve() - }) - }) - }) - - describe('with planReverts enabled in settings', function () { - beforeEach(function (ctx) { - ctx.settings.planReverts = { enabled: true } - }) - - describe('with no valid restore point', function () { - beforeEach(async function (ctx) { - await new Promise(resolve => { - ctx.SubscriptionHandler.getSubscriptionRestorePoint = sinon - .stub() - .yields(null, null) - ctx.SubscriptionHandler.revertPlanChange = sinon.stub() - - ctx.req.body = { - failed_payment_notification: { - transaction: { - subscription_id: 'subscription-123', - }, - }, - } - ctx.res = { - sendStatus() { - resolve() - }, - } - sinon.spy(ctx.res, 'sendStatus') - ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res) - }) - }) - it('should not call revertPlanChange()', function (ctx) { - expect(ctx.SubscriptionHandler.revertPlanChange.called).to.be.false - }) - - it('should respond with 200', function (ctx) { - ctx.res.sendStatus.calledWith(200) - }) - }) - - describe('with a valid restore point', function () { - beforeEach(async function (ctx) { - await new Promise(resolve => { - ctx.addOns = [ - { - addOnCode: 'addon-1', - quantity: 2, - unitAmountInCents: 500, - }, - { - addOnCode: 'addon-2', - quantity: 1, - unitAmountInCents: 600, - }, - ] - ctx.lastSubscription = { - planCode: 'gold', - addOns: ctx.addOns, - } - ctx.SubscriptionHandler.getSubscriptionRestorePoint = sinon - .stub() - .yields(null, ctx.lastSubscription) - ctx.SubscriptionHandler.revertPlanChange = sinon.stub().yields() - ctx.req.body = { - failed_payment_notification: { - transaction: { - subscription_id: 'subscription-123', - }, - }, - } - ctx.res = { - sendStatus() { - resolve() - }, - } - sinon.spy(ctx.res, 'sendStatus') - ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res) - }) - }) - - it('should get the subscription restore point', function (ctx) { - expect( - ctx.SubscriptionHandler.getSubscriptionRestorePoint.calledWith( - 'subscription-123' - ) - ).to.be.true - }) - - it('should call revertPlanChange()', function (ctx) { - expect( - ctx.SubscriptionHandler.revertPlanChange.calledWith( - 'subscription-123', - ctx.lastSubscription - ) - ).to.be.true - }) - - it('should respond with 200', function (ctx) { - ctx.res.sendStatus.calledWith(200) - }) - }) - }) - }) }) describe('purchaseAddon', function () { diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs index 82d8e5c660..3360175c8c 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionHandler.test.mjs @@ -105,8 +105,6 @@ describe('SubscriptionHandler', function () { .resolves(ctx.activeRecurlyClientSubscription), pauseSubscriptionByUuid: sinon.stub().resolves(), resumeSubscriptionByUuid: sinon.stub().resolves(), - failInvoice: sinon.stub(), - getPastDueInvoices: sinon.stub(), }, } ctx.SubscriptionUpdater = { @@ -115,7 +113,6 @@ describe('SubscriptionHandler', function () { syncSubscription: sinon.stub().resolves(), syncStripeSubscription: sinon.stub().resolves(), startFreeTrial: sinon.stub().resolves(), - setSubscriptionWasReverted: sinon.stub().resolves(), }, } @@ -1004,150 +1001,4 @@ describe('SubscriptionHandler', function () { }) }) }) - - describe('revertPlanChange', function () { - describe('with correct invoices', function () { - beforeEach(async function (ctx) { - ctx.subscriptionRestorePoint = { - planCode: 'collaborator', - addOns: [ - { addOnCode: 'addon-1', quantity: 1, unitAmountInCents: 500 }, - ], - _id: 'restore-point-id', - } - ctx.pastDueInvoice = { - id: 'invoice-123', - dueAt: new Date(), - collectionMethod: 'automatic', - } - ctx.user.id = ctx.activeRecurlySubscription.account.account_code - ctx.User.findById = (userId, projection) => ({ - exec: () => { - userId.should.equal(ctx.user.id) - return Promise.resolve(ctx.user) - }, - }) - ctx.RecurlyClient.promises.getSubscription.resolves( - ctx.activeRecurlyClientSubscription - ) - ctx.RecurlyClient.promises.getPastDueInvoices.resolves([ - ctx.pastDueInvoice, - ]) - ctx.RecurlyClient.promises.failInvoice.resolves() - ctx.SubscriptionUpdater.promises.setSubscriptionWasReverted.resolves() - ctx.RecurlyClient.promises.applySubscriptionChangeRequest.resolves() - - await ctx.SubscriptionHandler.promises.revertPlanChange( - ctx.activeRecurlyClientSubscription.id, - ctx.subscriptionRestorePoint - ) - }) - - it('should fetch the subscription from recurly', async function (ctx) { - expect( - ctx.RecurlyClient.promises.getSubscription.calledWith( - ctx.activeRecurlyClientSubscription.id - ) - ).to.be.true - }) - - it('should fail the invoice', async function (ctx) { - expect( - ctx.RecurlyClient.promises.failInvoice.calledWith( - ctx.pastDueInvoice.id - ) - ).to.be.true - }) - - it('should call setSubscriptionWasReverted', async function (ctx) { - expect( - ctx.SubscriptionUpdater.promises.setSubscriptionWasReverted.calledWith( - ctx.subscriptionRestorePoint._id - ) - ).to.be.true - }) - - it('should sync the subscription', async function (ctx) { - ctx.SubscriptionUpdater.promises.syncSubscription.calledOnce.should.equal( - true - ) - ctx.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal( - ctx.activeRecurlySubscription - ) - ctx.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal( - ctx.user._id - ) - }) - }) - - describe('should throw an IndeterminateInvoiceError when', function () { - beforeEach(function (ctx) { - ctx.subscriptionRestorePoint = { - planCode: 'collaborator', - addOns: [ - { addOnCode: 'addon-1', quantity: 1, unitAmountInCents: 500 }, - ], - _id: 'restore-point-id', - } - ctx.RecurlyClient.promises.getSubscription.resolves( - ctx.activeRecurlyClientSubscription - ) - }) - - it('finds a past due invoice older than 24 hours', async function (ctx) { - const oldInvoice = { - id: 'invoice-123', - dueAt: new Date(Date.now() - 25 * 60 * 60 * 1000), // 25 hours ago - collectionMethod: 'automatic', - } - ctx.RecurlyClient.promises.getPastDueInvoices.resolves([oldInvoice]) - - await expect( - ctx.SubscriptionHandler.promises.revertPlanChange( - ctx.activeRecurlyClientSubscription.id, - ctx.subscriptionRestorePoint - ) - ).to.be.rejectedWith('cant determine invoice to fail for plan revert') - }) - - it('finds more than one past due invoice', async function (ctx) { - const invoices = [ - { - id: 'invoice-123', - dueAt: new Date(), - collectionMethod: 'automatic', - }, - { - id: 'invoice-456', - dueAt: new Date(), - collectionMethod: 'automatic', - }, - ] - ctx.RecurlyClient.promises.getPastDueInvoices.resolves(invoices) - - await expect( - ctx.SubscriptionHandler.promises.revertPlanChange( - ctx.activeRecurlyClientSubscription.id, - ctx.subscriptionRestorePoint - ) - ).to.be.rejectedWith('cant determine invoice to fail for plan revert') - }) - - it('finds an invoice with a collectionMethod other than automatic', async function (ctx) { - const manualInvoice = { - id: 'invoice-123', - dueAt: new Date(), - collectionMethod: 'manual', - } - ctx.RecurlyClient.promises.getPastDueInvoices.resolves([manualInvoice]) - - await expect( - ctx.SubscriptionHandler.promises.revertPlanChange( - ctx.activeRecurlyClientSubscription.id, - ctx.subscriptionRestorePoint - ) - ).to.be.rejectedWith('cant determine invoice to fail for plan revert') - }) - }) - }) }) diff --git a/services/web/test/unit/src/Subscription/SubscriptionUpdater.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionUpdater.test.mjs index 3e3ecb96b1..9c4d108089 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionUpdater.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionUpdater.test.mjs @@ -1038,97 +1038,4 @@ describe('SubscriptionUpdater', function () { ).to.equal(4) }) }) - describe('setRestorePoint', function () { - it('should set the restore point with the given plan code and add-ons', async function (ctx) { - const subscriptionId = new ObjectId() - const planCode = 'gold-plan' - const addOns = [ - { addOnCode: 'addon-1', quantity: 2, unitAmountInCents: 500 }, - { addOnCode: 'addon-2', quantity: 1, unitAmountInCents: 1000 }, - ] - const consumed = false - - await ctx.SubscriptionUpdater.promises.setRestorePoint( - subscriptionId, - planCode, - addOns, - consumed - ) - - sinon.assert.calledWith( - ctx.SubscriptionModel.updateOne, - { _id: subscriptionId }, - { - $set: { - 'lastSuccesfulSubscription.planCode': planCode, - 'lastSuccesfulSubscription.addOns': addOns, - }, - } - ) - }) - - it('should increment revertedDueToFailedPayment if consumed is true', async function (ctx) { - const consumed = true - const subscriptionId = new ObjectId() - - await ctx.SubscriptionUpdater.promises.setRestorePoint( - subscriptionId, - null, - null, - consumed - ) - - sinon.assert.calledWith( - ctx.SubscriptionModel.updateOne, - { _id: subscriptionId }, - { - $set: { - 'lastSuccesfulSubscription.planCode': null, - 'lastSuccesfulSubscription.addOns': null, - }, - $inc: { timesRevertedDueToFailedPayment: 1 }, - } - ) - }) - }) - - describe('setSubscriptionWasReverted', function () { - it('should clear the restore point and mark the subscription as reverted', async function (ctx) { - const subscriptionId = new ObjectId().toString() - - await ctx.SubscriptionUpdater.promises.setSubscriptionWasReverted( - subscriptionId - ) - - ctx.SubscriptionModel.updateOne.should.have.been.calledWith( - { _id: subscriptionId }, - { - $set: { - 'lastSuccesfulSubscription.planCode': null, - 'lastSuccesfulSubscription.addOns': null, - }, - $inc: { timesRevertedDueToFailedPayment: 1 }, - } - ) - }) - }) - - describe('voidRestorePoint', function () { - it('should clear the restore point without marking the subscription as reverted', async function (ctx) { - const subscriptionId = new ObjectId().toString() - - await ctx.SubscriptionUpdater.promises.voidRestorePoint(subscriptionId) - - sinon.assert.calledWith( - ctx.SubscriptionModel.updateOne, - { _id: subscriptionId }, - { - $set: { - 'lastSuccesfulSubscription.planCode': null, - 'lastSuccesfulSubscription.addOns': null, - }, - } - ) - }) - }) })