import { vi, assert, expect } from 'vitest'
import sinon from 'sinon'
import MockRequest from '../helpers/MockRequest.mjs'
import MockResponse from '../helpers/MockResponse.mjs'
import SubscriptionErrors from '../../../../app/src/Features/Subscription/Errors.mjs'
import { PaymentServiceResourceNotFoundError } from '../../../../modules/subscriptions/app/src/PaymentServiceErrors.mjs'
import SubscriptionHelper from '../../../../app/src/Features/Subscription/SubscriptionHelper.mjs'
import { AI_ADD_ON_CODE } from '../../../../app/src/Features/Subscription/AiHelper.mjs'

const modulePath =
  '../../../../app/src/Features/Subscription/SubscriptionController.mjs'

const mockSubscriptions = {
  'subscription-123-active': {
    uuid: 'subscription-123-active',
    plan: {
      name: 'Gold',
      plan_code: 'gold',
    },
    current_period_ends_at: new Date(),
    state: 'active',
    unit_amount_in_cents: 999,
    account: {
      account_code: 'user-123',
    },
  },
}

describe('SubscriptionController', function () {
  beforeEach(async function (ctx) {
    ctx.logger = {
      debug: sinon.stub(),
      warn: sinon.stub(),
    }
    vi.doMock('@overleaf/logger', () => ({
      default: ctx.logger,
    }))

    ctx.user = {
      email: 'tom@yahoo.com',
      _id: 'one',
      signUpDate: new Date('2000-10-01'),
      emails: [{ email: 'tom@yahoo.com', confirmedAt: new Date('2000-10-02') }],
    }
    ctx.activeRecurlySubscription = mockSubscriptions['subscription-123-active']

    ctx.SessionManager = {
      getLoggedInUser: sinon.stub().callsArgWith(1, null, ctx.user),
      getLoggedInUserId: sinon.stub().returns(ctx.user._id),
      getSessionUser: sinon.stub().returns(ctx.user),
      isUserLoggedIn: sinon.stub().returns(true),
    }
    ctx.SubscriptionHandler = {
      createSubscription: sinon.stub().callsArgWith(3),
      updateSubscription: sinon.stub().callsArgWith(3),
      reactivateSubscription: sinon.stub().callsArgWith(1),
      cancelSubscription: sinon.stub().callsArgWith(1),
      syncSubscription: sinon.stub().yields(),
      attemptPaypalInvoiceCollection: sinon.stub().yields(),
      startFreeTrial: sinon.stub(),
      promises: {
        createSubscription: sinon.stub().resolves(),
        updateSubscription: sinon.stub().resolves(),
        reactivateSubscription: sinon.stub().resolves(),
        cancelSubscription: sinon.stub().resolves(),
        pauseSubscription: sinon.stub().resolves(),
        resumeSubscription: sinon.stub().resolves(),
        syncSubscription: sinon.stub().resolves(),
        attemptPaypalInvoiceCollection: sinon.stub().resolves(),
        startFreeTrial: sinon.stub().resolves(),
        purchaseAddon: sinon.stub().resolves(),
        previewAddonPurchase: sinon.stub().resolves({
          subscription: {
            currency: 'USD',
            netTerms: 0,
            periodEnd: new Date(),
            taxRate: 0,
          },
          immediateCharge: { amount: 0 },
          nextPlanCode: 'professional',
          nextPlanName: 'Pro',
          nextPlanPrice: 2000,
          nextAddOns: [],
          subtotal: 2000,
          tax: 0,
          total: 2000,
        }),
      },
    }

    ctx.LimitationsManager = {
      hasPaidSubscription: sinon.stub(),
      userHasSubscription: sinon
        .stub()
        .yields(null, { hasSubscription: false }),
      promises: {
        hasPaidSubscription: sinon.stub().resolves(),
        userHasSubscription: sinon.stub().resolves({ hasSubscription: false }),
      },
    }

    ctx.SubscriptionViewModelBuilder = {
      buildUsersSubscriptionViewModel: sinon.stub().callsArgWith(1, null, {}),
      buildPlansList: sinon.stub(),
      promises: {
        buildUsersSubscriptionViewModel: sinon.stub().resolves({}),
      },
      buildPlansListForSubscriptionDash: sinon
        .stub()
        .returns({ plans: [], planCodesChangingAtTermEnd: [] }),
    }
    ctx.settings = {
      coupon_codes: {
        upgradeToAnnualPromo: {
          student: 'STUDENTCODEHERE',
          collaborator: 'COLLABORATORCODEHERE',
        },
      },
      groupPlanModalOptions: {
        plan_codes: [],
        currencies: [
          {
            display: 'GBP (£)',
            code: 'GBP',
          },
        ],
        sizes: ['42'],
        usages: [{ code: 'foo', display: 'Foo' }],
      },
      apis: {
        recurly: {
          subdomain: 'sl',
        },
      },
      siteUrl: 'http://de.overleaf.dev:3000',
    }
    ctx.AuthorizationManager = {
      promises: {
        isUserSiteAdmin: sinon.stub().resolves(false),
      },
    }
    ctx.GeoIpLookup = {
      isValidCurrencyParam: sinon.stub().returns(true),
      getCurrencyCode: sinon.stub().yields('USD', 'US'),
      promises: {
        getCurrencyCode: sinon.stub().resolves({
          countryCode: 'US',
          currencyCode: 'USD',
        }),
      },
    }
    ctx.UserGetter = {
      getUser: sinon.stub().callsArgWith(2, null, ctx.user),
      promises: {
        getUser: sinon.stub().resolves(ctx.user),
        getWritefullData: sinon
          .stub()
          .resolves({ isPremium: false, premiumSource: null }),
      },
    }
    ctx.SplitTestV2Hander = {
      promises: {
        getAssignment: sinon.stub().resolves({ variant: 'default' }),
      },
    }
    ctx.Features = {
      hasFeature: sinon.stub().returns(false),
    }

    vi.doMock(
      '../../../../app/src/Features/Authorization/AuthorizationManager',
      () => ({
        default: ctx.AuthorizationManager,
      })
    )

    vi.doMock(
      '../../../../app/src/Features/SplitTests/SplitTestHandler',
      () => ({
        default: ctx.SplitTestV2Hander,
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Authentication/SessionManager',
      () => ({
        default: ctx.SessionManager,
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Subscription/SubscriptionHandler',
      () => ({
        default: ctx.SubscriptionHandler,
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Subscription/SubscriptionHelper',
      () => ({
        default: SubscriptionHelper,
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Subscription/SubscriptionViewModelBuilder',
      () => ({
        default: ctx.SubscriptionViewModelBuilder,
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Subscription/LimitationsManager',
      () => ({
        default: ctx.LimitationsManager,
      })
    )

    vi.doMock('../../../../app/src/infrastructure/GeoIpLookup', () => ({
      default: ctx.GeoIpLookup,
    }))

    vi.doMock('@overleaf/settings', () => ({
      default: ctx.settings,
    }))

    vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
      default: ctx.UserGetter,
    }))

    vi.doMock(
      '../../../../app/src/Features/Subscription/RecurlyWrapper',
      () => ({
        default: (ctx.RecurlyWrapper = {
          promises: {
            updateAccountEmailAddress: sinon.stub().resolves(),
            getSubscription: sinon.stub().resolves({}),
          },
        }),
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Subscription/RecurlyEventHandler',
      () => ({
        default: {
          sendRecurlyAnalyticsEvent: sinon.stub().resolves(),
        },
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Subscription/FeaturesUpdater',
      () => ({
        default: (ctx.FeaturesUpdater = {
          promises: {
            refreshFeatures: sinon.stub().resolves({ features: {} }),
          },
        }),
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Subscription/GroupPlansData',
      () => ({
        default: (ctx.GroupPlansData = {}),
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Subscription/V1SubscriptionManager',
      () => ({
        default: (ctx.V1SubscriptionManager = {}),
      })
    )

    vi.doMock('../../../../app/src/Features/Errors/HttpErrorHandler', () => ({
      default: (ctx.HttpErrorHandler = {
        unprocessableEntity: sinon.stub().callsFake((req, res, message) => {
          res.status(422)
          res.json({ message })
        }),
        badRequest: sinon.stub().callsFake((req, res, message) => {
          res.status(400)
          res.json({ message })
        }),
        forbidden: sinon.stub().callsFake((req, res, message) => {
          res.status(403)
          res.json({ message })
        }),
        notFound: sinon.stub().callsFake((req, res, message) => {
          res.status(404)
          res.json({ message })
        }),
      }),
    }))

    vi.doMock('../../../../app/src/Features/Subscription/Errors', () => ({
      default: SubscriptionErrors,
    }))

    vi.doMock(
      '../../../../app/src/Features/Analytics/AnalyticsManager',
      () => ({
        default: (ctx.AnalyticsManager = {
          recordEventForUser: sinon.stub(),
          recordEventForUserInBackground: sinon.stub(),
          recordEventForSession: sinon.stub(),
          setUserPropertyForUser: sinon.stub(),
        }),
      })
    )

    vi.doMock('../../../../app/src/infrastructure/Modules', () => ({
      default: (ctx.Modules = {
        promises: { hooks: { fire: sinon.stub().resolves() } },
      }),
    }))

    vi.doMock('../../../../app/src/infrastructure/Features', () => ({
      default: ctx.Features,
    }))

    vi.doMock('../../../../app/src/util/currency', () => ({
      default: (ctx.currency = {
        formatCurrency: sinon.stub(),
      }),
    }))

    vi.doMock('../../../../app/src/models/User', () => ({
      User: {
        findById: sinon.stub().resolves(ctx.user),
      },
    }))

    vi.doMock(
      '../../../../app/src/Features/Subscription/SubscriptionLocator',
      () => ({
        default: (ctx.SubscriptionLocator = {
          promises: {
            getUsersSubscription: sinon.stub().resolves(null),
          },
        }),
      })
    )

    vi.doMock(
      '../../../../app/src/Features/Authorization/PermissionsManager',
      () => ({
        default: (ctx.PermissionsManager = {
          promises: {
            checkUserPermissions: sinon.stub().resolves(true),
          },
        }),
      })
    )

    vi.doMock('../../../../app/src/Features/Subscription/PlansLocator', () => ({
      default: (ctx.PlansLocator = {
        findLocalPlanInSettings: sinon.stub().returns({
          annual: false,
        }),
      }),
    }))

    ctx.SubscriptionController = (await import(modulePath)).default

    ctx.res = new MockResponse(vi)
    ctx.req = new MockRequest(vi)
    ctx.req.body = {}
    ctx.req.query = { planCode: '123123' }

    ctx.stubbedCurrencyCode = 'GBP'
  })

  describe('successfulSubscription', function () {
    it('without a personal subscription', async function (ctx) {
      await new Promise(resolve => {
        ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
          {}
        )
        ctx.res.redirect = url => {
          url.should.equal('/user/subscription/plans')
          resolve()
        }
        ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
      })
    })

    it('with a personal subscription', async function (ctx) {
      await new Promise(resolve => {
        ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
          {
            personalSubscription: 'foo',
          }
        )
        ctx.res.render = (url, variables) => {
          url.should.equal('subscriptions/successful-subscription-react')
          assert.deepEqual(variables, {
            title: 'thank_you',
            personalSubscription: 'foo',
            postCheckoutRedirect: undefined,
            isUpgrade: false,
            user: {
              _id: ctx.user._id,
              features: ctx.user.features,
            },
          })
          resolve()
        }
        ctx.SubscriptionController.successfulSubscription(ctx.req, ctx.res)
      })
    })

    it('with an error', async function (ctx) {
      await new Promise(resolve => {
        ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
          undefined
        )
        ctx.SubscriptionController.successfulSubscription(
          ctx.req,
          ctx.res,
          error => {
            assert.isNotNull(error)
            resolve()
          }
        )
      })
    })
  })

  describe('userSubscriptionPage', function () {
    beforeEach(async function (ctx) {
      await new Promise((resolve, reject) => {
        ctx.SubscriptionViewModelBuilder.promises.buildUsersSubscriptionViewModel.resolves(
          {
            personalSubscription: (ctx.personalSubscription = {
              'personal-subscription': 'mock',
            }),
            memberGroupSubscriptions: (ctx.memberGroupSubscriptions = {
              'group-subscriptions': 'mock',
            }),
          }
        )
        ctx.SubscriptionViewModelBuilder.buildPlansList.returns(
          (ctx.plans = { plans: 'mock' })
        )
        ctx.SubscriptionViewModelBuilder.buildPlansListForSubscriptionDash.returns(
          {
            plans: ctx.plans,
            planCodesChangingAtTermEnd: [],
          }
        )
        ctx.LimitationsManager.promises.userHasSubscription.resolves({
          hasSubscription: false,
        })
        ctx.res.render = (view, data) => {
          ctx.data = data
          expect(view).to.equal('subscriptions/dashboard-react')
          resolve()
        }
        ctx.SubscriptionController.userSubscriptionPage(
          ctx.req,
          ctx.res,
          ctx.rejectOnError(reject)
        )
      })
    })

    it('should load the personal, groups and v1 subscriptions', function (ctx) {
      expect(ctx.data.personalSubscription).to.deep.equal(
        ctx.personalSubscription
      )
      expect(ctx.data.memberGroupSubscriptions).to.deep.equal(
        ctx.memberGroupSubscriptions
      )
    })

    it('should load the user', function (ctx) {
      expect(ctx.data.user).to.deep.equal(ctx.user)
    })

    it('should load the plans', function (ctx) {
      expect(ctx.data.plans).to.deep.equal(ctx.plans)
    })

    it('should load an empty list of groups with settings available', function (ctx) {
      expect(ctx.data.groupSettingsEnabledFor).to.deep.equal([])
    })

    it('should pass isManagedGroupAdmin as false when not set', function (ctx) {
      expect(ctx.data.isManagedGroupAdmin).to.equal(false)
    })

    describe('when user is a managed group admin', function () {
      beforeEach(async function (ctx) {
        ctx.req.isManagedGroupAdmin = true
        await new Promise((resolve, reject) => {
          ctx.res.render = (view, data) => {
            ctx.data = data
            expect(view).to.equal('subscriptions/dashboard-react')
            resolve()
          }
          ctx.SubscriptionController.userSubscriptionPage(
            ctx.req,
            ctx.res,
            ctx.rejectOnError(reject)
          )
        })
      })

      it('should pass isManagedGroupAdmin as true', function (ctx) {
        expect(ctx.data.isManagedGroupAdmin).to.equal(true)
      })
    })

    describe('when errorCode query param is present', function () {
      beforeEach(async function (ctx) {
        ctx.req.query.errorCode = 'payment_failed'
        await new Promise((resolve, reject) => {
          ctx.res.render = (view, data) => {
            ctx.data = data
            expect(view).to.equal('subscriptions/dashboard-react')
            resolve()
          }
          ctx.SubscriptionController.userSubscriptionPage(
            ctx.req,
            ctx.res,
            ctx.rejectOnError(reject)
          )
        })
      })

      it('should pass redirectedPaymentErrorCode to the view', function (ctx) {
        expect(ctx.data.redirectedPaymentErrorCode).to.equal('payment_failed')
      })
    })
  })

  describe('updateAccountEmailAddress via put', function () {
    beforeEach(function (ctx) {
      ctx.req.body = {
        account_email: 'current_account_email@overleaf.com',
      }
    })

    it('should send the user and subscriptionId to "updateAccountEmailAddress" hooks', async function (ctx) {
      ctx.res.sendStatus = sinon.spy()

      await ctx.SubscriptionController.updateAccountEmailAddress(
        ctx.req,
        ctx.res
      )

      expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
        'updateAccountEmailAddress',
        ctx.user._id,
        ctx.user.email
      )
    })

    it('should respond with 200', async function (ctx) {
      ctx.res.sendStatus = sinon.spy()
      await ctx.SubscriptionController.updateAccountEmailAddress(
        ctx.req,
        ctx.res
      )
      ctx.res.sendStatus.calledWith(200).should.equal(true)
    })

    it('should send the error to the next handler when updating recurly account email fails', async function (ctx) {
      ctx.Modules.promises.hooks.fire
        .withArgs('updateAccountEmailAddress', ctx.user._id, ctx.user.email)
        .rejects(new Error())

      ctx.next = sinon.spy(error => {
        expect(error).to.be.instanceOf(Error)
      })
      await ctx.SubscriptionController.updateAccountEmailAddress(
        ctx.req,
        ctx.res,
        ctx.next
      )
      expect(ctx.next.calledOnce).to.be.true
    })
  })

  describe('reactivateSubscription', function () {
    describe('when the user has permission', function () {
      beforeEach(async function (ctx) {
        await new Promise(resolve => {
          ctx.res = {
            redirect() {
              resolve()
            },
          }
          ctx.req.assertPermission = sinon.stub()
          ctx.next = sinon.stub().callsFake(error => {
            resolve(error)
          })
          sinon.spy(ctx.res, 'redirect')
          ctx.SubscriptionController.reactivateSubscription(
            ctx.req,
            ctx.res,
            ctx.next
          )
        })
      })

      it('should assert the user has permission to reactivate their subscription', async function (ctx) {
        await new Promise(resolve => {
          ctx.req.assertPermission
            .calledWith('reactivate-subscription')
            .should.equal(true)
          resolve()
        })
      })

      it('should tell the handler to reactivate this user', async function (ctx) {
        await new Promise(resolve => {
          ctx.SubscriptionHandler.reactivateSubscription
            .calledWith(ctx.user)
            .should.equal(true)
          resolve()
        })
      })

      it('should redurect to the subscription page', async function (ctx) {
        await new Promise(resolve => {
          ctx.res.redirect.calledWith('/user/subscription').should.equal(true)
          resolve()
        })
      })
    })

    describe('when the user does not have permission', function () {
      beforeEach(async function (ctx) {
        await new Promise(resolve => {
          ctx.res = {
            redirect() {
              resolve()
            },
          }
          ctx.req.assertPermission = sinon.stub().throws()
          ctx.next = sinon.stub().callsFake(() => {
            resolve()
          })
          sinon.spy(ctx.res, 'redirect')
          ctx.SubscriptionController.reactivateSubscription(
            ctx.req,
            ctx.res,
            ctx.next
          )
        })
      })

      it('should not reactivate the user', async function (ctx) {
        await new Promise(resolve => {
          ctx.req.assertPermission = sinon.stub().throws()
          ctx.SubscriptionHandler.reactivateSubscription.called.should.equal(
            false
          )
          resolve()
        })
      })

      it('should call next with an error', async function (ctx) {
        await new Promise(resolve => {
          ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
          resolve()
        })
      })
    })
  })

  describe('pauseSubscription', function () {
    beforeEach(function (ctx) {
      ctx.SplitTestV2Hander.promises.getAssignment
        .withArgs(sinon.match.any, sinon.match.any, 'pause-subscription')
        .resolves({ variant: 'enabled' })
    })

    it('should throw an error if no pause length is provided', async function (ctx) {
      ctx.res = new MockResponse(vi)
      ctx.req = new MockRequest(vi)
      ctx.next = sinon.stub()
      await expect(
        ctx.SubscriptionController.pauseSubscription(ctx.req, ctx.res, ctx.next)
      ).to.be.rejectedWith('Invalid request parameters')
    })

    it('should throw an error if an invalid pause length is provided', async function (ctx) {
      ctx.res = new MockResponse(vi)
      ctx.req = new MockRequest(vi)
      ctx.req.params = { pauseCycles: '-10' }
      ctx.next = sinon.stub()
      await ctx.SubscriptionController.pauseSubscription(
        ctx.req,
        ctx.res,
        ctx.next
      )
      expect(ctx.res.statusCode).to.equal(400)
    })

    it('should return a 200 when requesting a pause', async function (ctx) {
      ctx.res = new MockResponse(vi)
      ctx.req = new MockRequest(vi)
      ctx.req.params = { pauseCycles: '3' }
      ctx.next = sinon.stub()
      await ctx.SubscriptionController.pauseSubscription(
        ctx.req,
        ctx.res,
        ctx.next
      )
      expect(ctx.res.statusCode).to.equal(200)
    })

    it('should return a 403 when the pause-subscription feature flag is not enabled', async function (ctx) {
      ctx.SplitTestV2Hander.promises.getAssignment
        .withArgs(sinon.match.any, sinon.match.any, 'pause-subscription')
        .resolves({ variant: 'default' })
      ctx.res = new MockResponse(vi)
      ctx.req = new MockRequest(vi)
      ctx.req.params = { pauseCycles: '3' }
      ctx.next = sinon.stub()
      await ctx.SubscriptionController.pauseSubscription(
        ctx.req,
        ctx.res,
        ctx.next
      )
      expect(ctx.res.statusCode).to.equal(403)
      expect(
        ctx.SubscriptionHandler.promises.pauseSubscription.called
      ).to.equal(false)
    })
  })

  describe('resumeSubscription', function () {
    it('should return a 200 when resuming a subscription', async function (ctx) {
      ctx.res = new MockResponse(vi)
      ctx.req = new MockRequest(vi)
      ctx.next = sinon.stub()
      await ctx.SubscriptionController.resumeSubscription(
        ctx.req,
        ctx.res,
        ctx.next
      )
      expect(ctx.res.statusCode).to.equal(200)
    })
  })

  describe('cancelSubscription', function () {
    it('should tell the handler to cancel this user', async function (ctx) {
      ctx.next = sinon.stub()
      await ctx.SubscriptionController.cancelSubscription(
        ctx.req,
        ctx.res,
        ctx.next
      )
      ctx.SubscriptionHandler.promises.cancelSubscription
        .calledWith(ctx.user)
        .should.equal(true)
    })

    it('should return a 200 on success', async function (ctx) {
      ctx.next = sinon.stub()
      await ctx.SubscriptionController.cancelSubscription(
        ctx.req,
        ctx.res,
        ctx.next
      )
      expect(ctx.res.statusCode).to.equal(200)
    })

    it('should call next with error', async function (ctx) {
      ctx.SubscriptionHandler.promises.cancelSubscription.rejects(
        new Error('cancel error')
      )
      ctx.next = sinon.stub()
      await ctx.SubscriptionController.cancelSubscription(
        ctx.req,
        ctx.res,
        ctx.next
      )
      ctx.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true)
    })
  })

  describe('recurly callback', function () {
    describe('with a sync subscription request', function () {
      beforeEach(async function (ctx) {
        await new Promise(resolve => {
          ctx.req = {
            body: {
              expired_subscription_notification: {
                account: {
                  account_code: ctx.user._id,
                },
                subscription: {
                  uuid: ctx.activeRecurlySubscription.uuid,
                  plan: {
                    plan_code: 'collaborator',
                    state: 'active',
                  },
                },
              },
            },
          }
          ctx.res = {
            sendStatus() {
              resolve()
            },
          }
          sinon.spy(ctx.res, 'sendStatus')
          ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
        })
      })

      it('should tell the SubscriptionHandler to process the recurly callback', async function (ctx) {
        await new Promise(resolve => {
          ctx.SubscriptionHandler.syncSubscription.called.should.equal(true)
          resolve()
        })
      })

      it('should send a 200', async function (ctx) {
        await new Promise(resolve => {
          ctx.res.sendStatus.calledWith(200)
          resolve()
        })
      })
    })

    describe('with a billing info updated request', function () {
      beforeEach(async function (ctx) {
        await new Promise(resolve => {
          ctx.req = {
            body: {
              billing_info_updated_notification: {
                account: {
                  account_code: 'mock-account-code',
                },
              },
            },
          }
          ctx.res = {
            sendStatus() {
              resolve()
            },
          }
          sinon.spy(ctx.res, 'sendStatus')
          ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
        })
      })

      it('should call attemptPaypalInvoiceCollection', async function (ctx) {
        await new Promise(resolve => {
          ctx.SubscriptionHandler.attemptPaypalInvoiceCollection
            .calledWith('mock-account-code')
            .should.equal(true)
          resolve()
        })
      })

      it('should send a 200', async function (ctx) {
        await new Promise(resolve => {
          ctx.res.sendStatus.calledWith(200)
          resolve()
        })
      })
    })

    describe('with a non-actionable request', function () {
      beforeEach(async function (ctx) {
        await new Promise(resolve => {
          ctx.user.id = ctx.activeRecurlySubscription.account.account_code
          ctx.req = {
            body: {
              renewed_subscription_notification: {
                account: {
                  account_code: ctx.user._id,
                },
                subscription: {
                  uuid: ctx.activeRecurlySubscription.uuid,
                  plan: {
                    plan_code: 'collaborator',
                    state: 'active',
                  },
                },
              },
            },
          }
          ctx.res = {
            sendStatus() {
              resolve()
            },
          }
          sinon.spy(ctx.res, 'sendStatus')
          ctx.SubscriptionController.recurlyCallback(ctx.req, ctx.res)
        })
      })

      it('should not call the subscriptionshandler', function (ctx) {
        ctx.SubscriptionHandler.syncSubscription.called.should.equal(false)
        ctx.SubscriptionHandler.attemptPaypalInvoiceCollection.called.should.equal(
          false
        )
      })

      it('should respond with a 200 status', function (ctx) {
        ctx.res.sendStatus.calledWith(200)
      })
    })
  })

  describe('purchaseAddon', function () {
    beforeEach(function (ctx) {
      ctx.SessionManager.getSessionUser.returns(ctx.user) // Make sure getSessionUser returns the user
      ctx.next = sinon.stub()
      ctx.req.params = { addOnCode: AI_ADD_ON_CODE } // Mock add-on code
    })

    it('should return 200 on successful purchase of AI add-on', async function (ctx) {
      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)
      ctx.res.sendStatus = sinon.spy()

      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)

      expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.have.been.called
      expect(
        ctx.SubscriptionHandler.promises.purchaseAddon
      ).to.have.been.calledWith(ctx.user._id, AI_ADD_ON_CODE, 1)
      expect(
        ctx.FeaturesUpdater.promises.refreshFeatures
      ).to.have.been.calledWith(ctx.user._id, 'add-on-purchase')
      expect(ctx.res.sendStatus).to.have.been.calledWith(200)
      expect(ctx.logger.debug).to.have.been.calledWith(
        { userId: ctx.user._id, addOnCode: AI_ADD_ON_CODE },
        'purchasing add-ons'
      )
    })

    it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function (ctx) {
      ctx.req.params = { addOnCode: 'some-other-addon' }
      ctx.res.sendStatus = sinon.spy()

      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)

      expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.not.have.been
        .called
      expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
        .called
      expect(ctx.res.sendStatus).to.have.been.calledWith(404)
    })

    it('should return 404 when plans-2026-phase-1 split test is enabled', async function (ctx) {
      ctx.SplitTestV2Hander.promises.getAssignment.resolves({
        variant: 'enabled',
      })
      ctx.res.sendStatus = sinon.spy()

      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)

      expect(ctx.SubscriptionHandler.promises.purchaseAddon).to.not.have.been
        .called
      expect(ctx.res.sendStatus).to.have.been.calledWith(404)
    })

    it('should handle DuplicateAddOnError and send badRequest while sending 200', async function (ctx) {
      ctx.req.params.addOnCode = AI_ADD_ON_CODE
      ctx.SubscriptionHandler.promises.purchaseAddon.rejects(
        new SubscriptionErrors.DuplicateAddOnError()
      )

      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)

      expect(ctx.HttpErrorHandler.badRequest).to.have.been.calledWith(
        ctx.req,
        ctx.res,
        'Your subscription already includes this add-on',
        { addon: AI_ADD_ON_CODE }
      )
      expect(
        ctx.FeaturesUpdater.promises.refreshFeatures
      ).to.have.been.calledWith(ctx.user._id, 'add-on-purchase')
      expect(ctx.res.sendStatus).toHaveBeenCalledWith(200)
    })

    it('should handle PaymentActionRequiredError and return 402 with details', async function (ctx) {
      ctx.req.params.addOnCode = AI_ADD_ON_CODE
      const paymentError = new SubscriptionErrors.PaymentActionRequiredError({
        clientSecret: 'secret123',
        publicKey: 'pubkey456',
      })
      ctx.SubscriptionHandler.promises.purchaseAddon.rejects(paymentError)

      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)

      expect(ctx.res.status).toHaveBeenCalledWith(402)
      expect(ctx.res.json).toHaveBeenCalledWith({
        message: 'Payment action required',
        clientSecret: 'secret123',
        publicKey: 'pubkey456',
      })

      expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
        .called
    })

    it('should refresh features', async function (ctx) {
      ctx.req.params.addOnCode = 'assistant'
      ctx.SubscriptionHandler.promises.purchaseAddon = sinon.stub().resolves()
      ctx.FeaturesUpdater.promises.refreshFeatures = sinon.stub().resolves()

      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res)

      expect(
        ctx.FeaturesUpdater.promises.refreshFeatures.calledWith(
          ctx.user._id,
          'add-on-purchase'
        )
      ).to.be.true
    })

    it('should respond with a bad request if the subscription already includes the addOn', async function (ctx) {
      ctx.req.params.addOnCode = 'assistant'
      ctx.SubscriptionHandler.promises.purchaseAddon = sinon
        .stub()
        .rejects(new SubscriptionErrors.DuplicateAddOnError())

      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res)

      expect(
        ctx.HttpErrorHandler.badRequest.calledWith(
          ctx.req,
          ctx.res,
          'Your subscription already includes this add-on',
          { addon: 'assistant' }
        )
      ).to.be.true
    })

    it('should handle MultiplePendingChangesError and return 422 with JSON response', async function (ctx) {
      ctx.req.params.addOnCode = AI_ADD_ON_CODE
      ctx.SubscriptionHandler.promises.purchaseAddon.rejects(
        new SubscriptionErrors.MultiplePendingChangesError()
      )

      await ctx.SubscriptionController.purchaseAddon(ctx.req, ctx.res, ctx.next)

      expect(ctx.res.status).toHaveBeenCalledWith(422)
      expect(ctx.res.json).toHaveBeenCalledWith({
        code: 'multiple_pending_changes',
        message:
          'Cannot complete purchase while there are multiple pending subscription changes. Please contact support.',
      })
      expect(ctx.FeaturesUpdater.promises.refreshFeatures).to.not.have.been
        .called
    })
  })

  describe('removeAddon', function () {
    beforeEach(function (ctx) {
      ctx.SessionManager.getSessionUser.returns(ctx.user)
      ctx.next = sinon.stub()
      ctx.req.params = { addOnCode: AI_ADD_ON_CODE }
      ctx.SubscriptionHandler.promises.removeAddon = sinon.stub().resolves()
    })

    it('should return 200 on successful removal of AI add-on', async function (ctx) {
      ctx.res.sendStatus = sinon.spy()

      await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next)

      expect(ctx.SubscriptionHandler.promises.removeAddon).to.have.been.called
      expect(
        ctx.SubscriptionHandler.promises.removeAddon
      ).to.have.been.calledWith(ctx.user, AI_ADD_ON_CODE)
      expect(ctx.res.sendStatus).to.have.been.calledWith(200)
      expect(ctx.logger.debug).to.have.been.calledWith(
        { userId: ctx.user._id, addOnCode: AI_ADD_ON_CODE },
        'removing add-ons'
      )
    })

    it('should return 404 if the add-on code is not AI_ADD_ON_CODE', async function (ctx) {
      ctx.req.params = { addOnCode: 'some-other-addon' }
      ctx.res.sendStatus = sinon.spy()

      await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next)

      expect(ctx.SubscriptionHandler.promises.removeAddon).to.not.have.been
        .called
      expect(ctx.res.sendStatus).to.have.been.calledWith(404)
    })

    it('should handle AddOnNotPresentError and send badRequest', async function (ctx) {
      ctx.SubscriptionHandler.promises.removeAddon.rejects(
        new SubscriptionErrors.AddOnNotPresentError()
      )

      await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next)

      expect(ctx.HttpErrorHandler.badRequest).to.have.been.calledWith(
        ctx.req,
        ctx.res,
        'Your subscription does not contain the requested add-on',
        { addon: AI_ADD_ON_CODE }
      )
    })

    it('should handle MultiplePendingChangesError and return 422 with JSON response', async function (ctx) {
      ctx.SubscriptionHandler.promises.removeAddon.rejects(
        new SubscriptionErrors.MultiplePendingChangesError()
      )

      await ctx.SubscriptionController.removeAddon(ctx.req, ctx.res, ctx.next)

      expect(ctx.res.status).toHaveBeenCalledWith(422)
      expect(ctx.res.json).toHaveBeenCalledWith({
        code: 'multiple_pending_changes',
        message:
          'Cannot remove add-on while there are multiple pending subscription changes. Please contact support.',
      })
    })
  })

  describe('checkSubscriptionPauseStatus', function () {
    beforeEach(function (ctx) {
      ctx.user = {
        _id: 'user-id-123',
        email: 'test@example.com',
      }
    })

    it('should return isPaused: false when user has no subscription', async function (ctx) {
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription: null,
      })

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({ isPaused: false })
    })

    it('should return isPaused: false when subscription has no paymentProvider', async function (ctx) {
      const subscription = {
        planCode: 'professional',
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({ isPaused: false })
    })

    it('should return isPaused: false when subscription has no subscriptionId', async function (ctx) {
      const subscription = {
        paymentProvider: {
          service: 'stripe',
          subscriptionId: null,
        },
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({ isPaused: false })
    })

    it('should return isPaused: false when Stripe subscription has no remaining pause cycles', async function (ctx) {
      const subscription = {
        paymentProvider: {
          service: 'stripe',
          subscriptionId: 'sub-123',
        },
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const paymentRecord = {
        subscription: {
          remainingPauseCycles: 0,
        },
      }
      ctx.Modules.promises.hooks.fire
        .withArgs('getPaymentFromRecord', subscription)
        .resolves([paymentRecord])

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({ isPaused: false })
    })

    it('should return isPaused: false when Stripe subscription has no remainingPauseCycles property', async function (ctx) {
      const subscription = {
        paymentProvider: {
          service: 'stripe',
          subscriptionId: 'sub-123',
        },
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const paymentRecord = {
        subscription: {},
      }
      ctx.Modules.promises.hooks.fire
        .withArgs('getPaymentFromRecord', subscription)
        .resolves([paymentRecord])

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({ isPaused: false })
    })

    it('should return isPaused: true with redirect path when Stripe subscription has remaining pause cycles', async function (ctx) {
      const subscription = {
        paymentProvider: {
          service: 'stripe',
          subscriptionId: 'sub-123',
        },
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const paymentRecord = {
        subscription: {
          remainingPauseCycles: 2,
        },
      }
      ctx.Modules.promises.hooks.fire
        .withArgs('getPaymentFromRecord', subscription)
        .resolves([paymentRecord])

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({
        isPaused: true,
        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
      })
    })

    it('should return isPaused: true when remainingPauseCycles is exactly 1', async function (ctx) {
      const subscription = {
        paymentProvider: {
          service: 'stripe',
          subscriptionId: 'sub-123',
        },
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const paymentRecord = {
        subscription: {
          remainingPauseCycles: 1,
        },
      }
      ctx.Modules.promises.hooks.fire
        .withArgs('getPaymentFromRecord', subscription)
        .resolves([paymentRecord])

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({
        isPaused: true,
        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
      })
    })

    it('should return isPaused: false when userHasSubscription throws error', async function (ctx) {
      const error = new Error('Something bad happened')
      ctx.LimitationsManager.promises.userHasSubscription.rejects(error)

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({ isPaused: false })
    })

    it('should return isPaused: false when getPaymentFromRecord throws error', async function (ctx) {
      const subscription = {
        paymentProvider: {
          service: 'stripe',
          subscriptionId: 'sub-123',
        },
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const error = new Error('Something bad happened')
      ctx.Modules.promises.hooks.fire
        .withArgs('getPaymentFromRecord', subscription)
        .rejects(error)

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({ isPaused: false })
    })

    it('should return isPaused: false when Recurly subscription is not paused', async function (ctx) {
      const subscription = {
        recurlySubscription_id: 'uuid-123',
        recurlyStatus: {
          state: 'active',
        },
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({ isPaused: false })
    })

    it('should return isPaused: true when Recurly subscription is paused', async function (ctx) {
      const subscription = {
        recurlySubscription_id: 'uuid-123',
        recurlyStatus: {
          state: 'paused',
        },
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({
        isPaused: true,
        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
      })
    })

    it('should return isPaused: true when Recurly subscription has pending pause cycles', async function (ctx) {
      const subscription = {
        recurlySubscription_id: 'uuid-123',
        recurlyStatus: {
          state: 'active',
        },
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const recurlySubscriptionData = {
        remaining_pause_cycles: 2,
      }
      ctx.RecurlyWrapper.promises.getSubscription.resolves(
        recurlySubscriptionData
      )

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({
        isPaused: true,
        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
      })
      expect(
        ctx.RecurlyWrapper.promises.getSubscription
      ).to.have.been.calledWith('uuid-123')
    })

    it('should return isPaused: false when Recurly subscription has no remaining pause cycles', async function (ctx) {
      const subscription = {
        recurlySubscription_id: 'uuid-123',
        recurlyStatus: {
          state: 'active',
        },
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const recurlySubscriptionData = {
        remaining_pause_cycles: 0,
      }
      ctx.RecurlyWrapper.promises.getSubscription.resolves(
        recurlySubscriptionData
      )

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({ isPaused: false })
    })

    it('should return isPaused: false when Recurly subscription has no remaining_pause_cycles property', async function (ctx) {
      const subscription = {
        recurlySubscription_id: 'uuid-123',
        recurlyStatus: {
          state: 'active',
        },
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const recurlySubscriptionData = {}
      ctx.RecurlyWrapper.promises.getSubscription.resolves(
        recurlySubscriptionData
      )

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({ isPaused: false })
    })

    it('should return isPaused: false when Recurly API call fails', async function (ctx) {
      const subscription = {
        recurlySubscription_id: 'uuid-123',
        recurlyStatus: {
          state: 'active',
        },
      }
      ctx.LimitationsManager.promises.userHasSubscription.resolves({
        subscription,
      })

      const error = new Error('Recurly API failed')
      ctx.RecurlyWrapper.promises.getSubscription.rejects(error)

      const result =
        await ctx.SubscriptionController.checkSubscriptionPauseStatus(ctx.user)

      expect(result).to.deep.equal({ isPaused: false })
    })
  })

  describe('makeChangePreview', function () {
    let pendingChange, baseSubscription, subscriptionChange

    beforeEach(function (ctx) {
      pendingChange = {
        nextPlanCode: 'student',
        nextPlanName: 'Student',
        nextPlanPrice: 1000,
        nextAddOns: [],
      }

      baseSubscription = {
        currency: 'USD',
        netTerms: 0,
        periodEnd: new Date('2027-04-29'),
        taxRate: 0,
        pendingChange,
      }

      subscriptionChange = {
        subscription: baseSubscription,
        nextPlanCode: 'professional',
        nextPlanName: 'Professional',
        nextPlanPrice: 2000,
        nextAddOns: [],
        immediateCharge: {
          subtotal: 0,
          tax: 0,
          total: 0,
          discount: 0,
          lineItems: [],
        },
      }
    })

    it('uses subscriptionChange plan for future invoice when type is premium-subscription', function (ctx) {
      const preview = ctx.SubscriptionController.makeChangePreview(
        {
          type: 'premium-subscription',
          plan: { code: 'professional', name: 'Professional' },
        },
        subscriptionChange
      )
      expect(preview.nextInvoice.plan.name).to.equal('Professional')
      expect(preview.nextInvoice.plan.amount).to.equal(2000)
    })

    it('uses subscriptionChange plan for future invoice when type is group-plan-upgrade', function (ctx) {
      const preview = ctx.SubscriptionController.makeChangePreview(
        { type: 'group-plan-upgrade', prevPlan: { name: 'Standard' } },
        subscriptionChange
      )
      expect(preview.nextInvoice.plan.name).to.equal('Professional')
      expect(preview.nextInvoice.plan.amount).to.equal(2000)
    })

    it('uses pendingChange plan for future invoice when type is add-on-purchase', function (ctx) {
      const preview = ctx.SubscriptionController.makeChangePreview(
        {
          type: 'add-on-purchase',
          addOn: { code: 'ai-assist', name: 'AI Assist' },
        },
        subscriptionChange
      )
      expect(preview.nextInvoice.plan.name).to.equal('Student')
      expect(preview.nextInvoice.plan.amount).to.equal(1000)
    })

    it('uses subscriptionChange plan for future invoice when there is no pending change', function (ctx) {
      baseSubscription.pendingChange = undefined
      const preview = ctx.SubscriptionController.makeChangePreview(
        {
          type: 'premium-subscription',
          plan: { code: 'professional', name: 'Professional' },
        },
        subscriptionChange
      )
      expect(preview.nextInvoice.plan.name).to.equal('Professional')
      expect(preview.nextInvoice.plan.amount).to.equal(2000)
    })

    describe('nextInvoice.date', function () {
      it('uses subscription.periodEnd when cadence does not change', function (ctx) {
        baseSubscription.pendingChange = undefined
        baseSubscription.planCode = 'collaborator'
        ctx.PlansLocator.findLocalPlanInSettings = sinon
          .stub()
          .returns({ annual: false, price_in_cents: 2300 })
        const preview = ctx.SubscriptionController.makeChangePreview(
          {
            type: 'premium-subscription',
            plan: { code: 'professional', name: 'Professional' },
          },
          subscriptionChange
        )
        expect(preview.nextInvoice.date).to.equal(
          new Date('2027-04-29').toISOString()
        )
      })

      it('uses now + 1 year on a monthly → annual upgrade (applied immediately)', function (ctx) {
        baseSubscription.pendingChange = undefined
        baseSubscription.planCode = 'student'
        ctx.PlansLocator.findLocalPlanInSettings = sinon.stub()
        ctx.PlansLocator.findLocalPlanInSettings
          .withArgs('student')
          .returns({ annual: false, price_in_cents: 1000 })
        ctx.PlansLocator.findLocalPlanInSettings
          .withArgs('collaborator-annual')
          .returns({ annual: true, price_in_cents: 21900 })
        subscriptionChange.nextPlanCode = 'collaborator-annual'

        const before = new Date()
        const preview = ctx.SubscriptionController.makeChangePreview(
          {
            type: 'premium-subscription',
            plan: { code: 'collaborator-annual', name: 'Standard annual' },
          },
          subscriptionChange
        )
        const after = new Date()

        const date = new Date(preview.nextInvoice.date)
        const minExpected = new Date(before)
        minExpected.setFullYear(minExpected.getFullYear() + 1)
        const maxExpected = new Date(after)
        maxExpected.setFullYear(maxExpected.getFullYear() + 1)
        expect(date.getTime()).to.be.at.least(minExpected.getTime())
        expect(date.getTime()).to.be.at.most(maxExpected.getTime())
      })

      it('uses now + 1 month on an annual → monthly cadence flip while in trial (applied immediately)', function (ctx) {
        // shouldPlanChangeAtTermEnd returns false during a trial regardless
        // of price direction, so the override applies and the next invoice
        // is one new term from today.
        baseSubscription.pendingChange = undefined
        baseSubscription.planCode = 'collaborator-annual'
        baseSubscription.trialPeriodEnd = new Date(
          Date.now() + 24 * 60 * 60 * 1000
        )
        ctx.PlansLocator.findLocalPlanInSettings = sinon.stub()
        ctx.PlansLocator.findLocalPlanInSettings
          .withArgs('collaborator-annual')
          .returns({ annual: true, price_in_cents: 21900 })
        ctx.PlansLocator.findLocalPlanInSettings
          .withArgs('collaborator')
          .returns({ annual: false, price_in_cents: 2300 })
        subscriptionChange.nextPlanCode = 'collaborator'

        const before = new Date()
        const preview = ctx.SubscriptionController.makeChangePreview(
          {
            type: 'premium-subscription',
            plan: { code: 'collaborator', name: 'Standard' },
          },
          subscriptionChange
        )
        const after = new Date()

        const date = new Date(preview.nextInvoice.date)
        const minExpected = new Date(before)
        minExpected.setMonth(minExpected.getMonth() + 1)
        const maxExpected = new Date(after)
        maxExpected.setMonth(maxExpected.getMonth() + 1)
        expect(date.getTime()).to.be.at.least(minExpected.getTime())
        expect(date.getTime()).to.be.at.most(maxExpected.getTime())
      })

      it('keeps subscription.periodEnd on an annual → monthly cadence flip (scheduled at term end)', function (ctx) {
        // shouldPlanChangeAtTermEnd returns true for this case
        // (annual yearly cents > monthly cents), so the change is deferred
        // and the next invoice lands at the existing annual period end —
        // not one month from now.
        baseSubscription.pendingChange = undefined
        baseSubscription.planCode = 'collaborator-annual'
        ctx.PlansLocator.findLocalPlanInSettings = sinon.stub()
        ctx.PlansLocator.findLocalPlanInSettings
          .withArgs('collaborator-annual')
          .returns({ annual: true, price_in_cents: 21900 })
        ctx.PlansLocator.findLocalPlanInSettings
          .withArgs('collaborator')
          .returns({ annual: false, price_in_cents: 2300 })
        subscriptionChange.nextPlanCode = 'collaborator'

        const preview = ctx.SubscriptionController.makeChangePreview(
          {
            type: 'premium-subscription',
            plan: { code: 'collaborator', name: 'Standard' },
          },
          subscriptionChange
        )
        expect(preview.nextInvoice.date).to.equal(
          new Date('2027-04-29').toISOString()
        )
      })

      it('falls back to subscription.periodEnd if current plan cannot be resolved', function (ctx) {
        baseSubscription.pendingChange = undefined
        baseSubscription.planCode = 'unknown-plan'
        ctx.PlansLocator.findLocalPlanInSettings = sinon.stub()
        ctx.PlansLocator.findLocalPlanInSettings
          .withArgs('unknown-plan')
          .returns(null)
        ctx.PlansLocator.findLocalPlanInSettings
          .withArgs('collaborator-annual')
          .returns({ annual: true, price_in_cents: 21900 })
        subscriptionChange.nextPlanCode = 'collaborator-annual'

        const preview = ctx.SubscriptionController.makeChangePreview(
          {
            type: 'premium-subscription',
            plan: { code: 'collaborator-annual', name: 'Standard annual' },
          },
          subscriptionChange
        )
        expect(preview.nextInvoice.date).to.equal(
          new Date('2027-04-29').toISOString()
        )
      })
    })

    it('prefers the local plan name over the legacy payment-provider name for the future invoice', function (ctx) {
      baseSubscription.pendingChange = undefined
      ctx.PlansLocator.findLocalPlanInSettings
        .withArgs('professional')
        .returns({
          planCode: 'professional',
          name: 'Pro monthly',
          annual: false,
        })
      const preview = ctx.SubscriptionController.makeChangePreview(
        {
          type: 'premium-subscription',
          plan: { code: 'professional', name: 'Pro monthly' },
        },
        subscriptionChange
      )
      expect(preview.nextInvoice.plan.name).to.equal('Pro monthly')
    })
  })

  describe('previewSubscription', function () {
    beforeEach(function (ctx) {
      ctx.req = new MockRequest(vi)
      ctx.req.query = { planCode: 'collaborator' }
      ctx.res = new MockResponse(vi)
      ctx.res.render = sinon.stub()

      ctx.PlansLocator.findLocalPlanInSettings.returns({
        planCode: 'collaborator',
        name: 'Standard monthly',
        annual: false,
      })

      ctx.SubscriptionHandler.promises.previewSubscriptionChange = sinon
        .stub()
        .resolves({
          subscription: {
            currency: 'USD',
            netTerms: 0,
            periodEnd: new Date('2027-04-29'),
            taxRate: 0,
          },
          nextPlanCode: 'collaborator',
          nextPlanName: 'Standard monthly',
          nextPlanPrice: 2300,
          nextAddOns: [],
          immediateCharge: { subtotal: 0, tax: 0, total: 0, discount: 0 },
          subtotal: 2300,
          tax: 0,
          total: 2300,
        })

      ctx.Modules.promises.hooks.fire
        .withArgs('getPaymentMethod')
        .resolves(['fake-method'])
    })

    it('renders the renamed local plan name in changePreview.change.plan', async function (ctx) {
      await ctx.SubscriptionController.previewSubscription(ctx.req, ctx.res)

      expect(ctx.res.render).to.have.been.calledWith(
        'subscriptions/preview-change',
        sinon.match({
          changePreview: sinon.match({
            change: {
              type: 'premium-subscription',
              plan: { code: 'collaborator', name: 'Standard monthly' },
            },
          }),
        })
      )
      expect(ctx.PlansLocator.findLocalPlanInSettings).to.have.been.calledWith(
        'collaborator'
      )
    })

    it('returns 404 when planCode is missing', async function (ctx) {
      ctx.req.query = {}

      await ctx.SubscriptionController.previewSubscription(ctx.req, ctx.res)

      expect(ctx.HttpErrorHandler.notFound).to.have.been.calledWith(
        ctx.req,
        ctx.res,
        'Missing plan code'
      )
      expect(ctx.res.render).not.to.have.been.called
    })

    it('returns 404 when planCode is unknown to the local plan registry', async function (ctx) {
      ctx.req.query = { planCode: 'does-not-exist' }
      ctx.PlansLocator.findLocalPlanInSettings.returns(null)

      await ctx.SubscriptionController.previewSubscription(ctx.req, ctx.res)

      expect(ctx.HttpErrorHandler.notFound).to.have.been.calledWith(
        ctx.req,
        ctx.res,
        'Unknown plan: does-not-exist'
      )
      expect(ctx.res.render).not.to.have.been.called
    })

    it('redirects to the plans page when the user has no subscription to preview', async function (ctx) {
      ctx.SubscriptionHandler.promises.previewSubscriptionChange = sinon
        .stub()
        .rejects(new PaymentServiceResourceNotFoundError('no subscription'))
      ctx.res.redirect = sinon.stub()

      await ctx.SubscriptionController.previewSubscription(ctx.req, ctx.res)

      expect(ctx.res.redirect).to.have.been.calledWith(
        '/user/subscription/plans'
      )
      expect(ctx.res.render).not.to.have.been.called
    })

    it('passes trialDisabledReason to the view when the user is ineligible for a free trial', async function (ctx) {
      ctx.req.query = { planCode: 'collaborator_free_trial_7_days' }
      ctx.PlansLocator.findLocalPlanInSettings.returns({
        planCode: 'collaborator_free_trial_7_days',
        name: 'Standard monthly',
        annual: false,
      })
      ctx.Modules.promises.hooks.fire
        .withArgs('userCanStartTrial', ctx.user)
        .resolves([{ canStartTrial: false, disabledReason: 'already-used' }])

      await ctx.SubscriptionController.previewSubscription(ctx.req, ctx.res)

      expect(ctx.res.render).to.have.been.calledWith(
        'subscriptions/preview-change',
        sinon.match({
          trialDisabledReason: 'already-used',
        })
      )
    })
  })

  describe('previewAddonPurchase', function () {
    beforeEach(function (ctx) {
      ctx.req = new MockRequest(vi)
      ctx.req.params = { addOnCode: 'assistant' }
      ctx.req.query = { purchaseReferrer: 'fake-referrer' }
      ctx.res = new MockResponse(vi)

      ctx.Modules.promises.hooks.fire
        .withArgs('getPaymentMethod')
        .resolves(['fake-method'])
      ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(null)
    })

    it('should redirect with ai-assist-unavailable when plans-2026-phase-1 is enabled', async function (ctx) {
      ctx.SplitTestV2Hander.promises.getAssignment.resolves({
        variant: 'enabled',
      })
      ctx.res.redirect = sinon.stub()

      await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)

      expect(ctx.res.redirect).to.have.been.calledWith(
        '/user/subscription?redirect-reason=ai-assist-unavailable'
      )
    })

    describe('when user has manual or custom subscription', function () {
      it('should redirect with ai-assist-unavailable when subscription has customAccount = true', async function (ctx) {
        const customSubscription = {
          _id: 'sub-123',
          customAccount: true,
          collectionMethod: 'automatic',
        }
        ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
          customSubscription
        )

        ctx.res.redirect = sinon.stub()

        await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)

        expect(ctx.res.redirect).to.have.been.calledWith(
          '/user/subscription?redirect-reason=ai-assist-unavailable'
        )
      })

      it('should redirect with ai-assist-unavailable when subscription has collectionMethod = manual', async function (ctx) {
        const manualSubscription = {
          _id: 'sub-123',
          customAccount: false,
          collectionMethod: 'manual',
        }
        ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
          manualSubscription
        )

        ctx.res.redirect = sinon.stub()

        await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)

        expect(ctx.res.redirect).to.have.been.calledWith(
          '/user/subscription?redirect-reason=ai-assist-unavailable'
        )
      })

      it('should redirect with ai-assist-unavailable when subscription has both customAccount and manual collection', async function (ctx) {
        const customManualSubscription = {
          _id: 'sub-123',
          customAccount: true,
          collectionMethod: 'manual',
        }
        ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
          customManualSubscription
        )

        ctx.res.redirect = sinon.stub()

        await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)

        expect(ctx.res.redirect).to.have.been.calledWith(
          '/user/subscription?redirect-reason=ai-assist-unavailable'
        )
      })
    })

    describe('when user has normal subscription', function () {
      it('should proceed with preview when subscription is not manual or custom', async function (ctx) {
        const normalSubscription = {
          _id: 'sub-123',
          customAccount: false,
          collectionMethod: 'automatic',
        }
        ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
          normalSubscription
        )
        ctx.PlansLocator.findLocalPlanInSettings.withArgs('assistant').returns({
          planCode: 'assistant',
          name: 'AI Assist',
          annual: false,
        })

        ctx.res.render = sinon.stub()

        await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)

        expect(ctx.res.render).to.have.been.calledWith(
          'subscriptions/preview-change',
          sinon.match({
            changePreview: sinon.match({
              change: {
                type: 'add-on-purchase',
                addOn: { code: 'assistant', name: 'AI Assist' },
              },
            }),
            purchaseReferrer: 'fake-referrer',
            redirectedPaymentErrorCode: undefined,
          })
        )
        expect(
          ctx.SubscriptionHandler.promises.previewAddonPurchase
        ).to.have.been.calledWith(ctx.user._id, 'assistant')
        expect(
          ctx.PlansLocator.findLocalPlanInSettings
        ).to.have.been.calledWith('assistant')
      })

      it('should pass redirectedPaymentErrorCode to the view when errorCode query param is present', async function (ctx) {
        const normalSubscription = {
          _id: 'sub-123',
          customAccount: false,
          collectionMethod: 'automatic',
        }
        ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
          normalSubscription
        )
        ctx.req.query.errorCode = 'payment_failed'

        ctx.res.render = sinon.stub()

        await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)

        expect(ctx.res.render).to.have.been.calledWith(
          'subscriptions/preview-change',
          sinon.match({
            changePreview: sinon.match.object,
            purchaseReferrer: 'fake-referrer',
            redirectedPaymentErrorCode: 'payment_failed',
          })
        )
      })

      it('returns 404 when the add-on code is not in the local plan registry', async function (ctx) {
        const normalSubscription = {
          _id: 'sub-123',
          customAccount: false,
          collectionMethod: 'automatic',
        }
        ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
          normalSubscription
        )
        ctx.PlansLocator.findLocalPlanInSettings
          .withArgs('assistant')
          .returns(null)

        ctx.res.render = sinon.stub()

        await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)

        expect(ctx.HttpErrorHandler.notFound).to.have.been.calledWith(
          ctx.req,
          ctx.res,
          'Unknown add-on: assistant'
        )
        expect(ctx.res.render).not.to.have.been.called
      })

      it('should proceed with preview when customAccount is undefined and collectionMethod is automatic', async function (ctx) {
        const normalSubscription = {
          _id: 'sub-123',
          // customAccount: undefined (not set)
          collectionMethod: 'automatic',
        }
        ctx.SubscriptionLocator.promises.getUsersSubscription.resolves(
          normalSubscription
        )

        ctx.res.render = sinon.stub()

        await ctx.SubscriptionController.previewAddonPurchase(ctx.req, ctx.res)

        expect(ctx.res.render).to.have.been.calledWith(
          'subscriptions/preview-change'
        )
        expect(
          ctx.SubscriptionHandler.promises.previewAddonPurchase
        ).to.have.been.calledWith(ctx.user._id, 'assistant')
      })
    })
  })
})
