diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.mjs b/services/web/app/src/Features/Subscription/SubscriptionController.mjs index 6066e99ef4..f28ff9b6a4 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionController.mjs @@ -49,6 +49,8 @@ const SUBSCRIPTION_PAUSED_REDIRECT_PATH = /** * @typedef {import('../../../../types/subscription/currency').CurrencyCode} CurrencyCode + * @typedef {import('./PaymentProviderEntities.mjs').PaymentProviderSubscription} PaymentProviderSubscription + * @typedef {import('../../../../types/subscription/plan').Plan} Plan */ /** @@ -1206,6 +1208,48 @@ function getPlanNameForDisplay(planName, planCode) { return `${prefix} group${suffix}` } +/** + * Compute the date displayed as the user's next invoice on the preview page. + * + * Default: the current cycle's end (`subscription.periodEnd`). + * + * Exception: when the change is applied immediately AND flips cadence + * (monthly ↔ annual), the user starts a new term today and the next invoice + * lands one new-term-length from now. We reuse + * `SubscriptionHelper.shouldPlanChangeAtTermEnd` so the immediate-vs-deferred + * decision stays in step with the apply path (including the trial case). + * + * @param {PaymentProviderSubscription} subscription + * @param {Plan | null | undefined} currentPlan Plan settings for the current plan, or null/undefined when unknown. + * @param {Plan | null | undefined} nextPlan Plan settings for the post-change plan, or null/undefined when unknown. + * @return {Date} + */ +function _getNextInvoiceDate(subscription, currentPlan, nextPlan) { + if (currentPlan == null || nextPlan == null) { + return subscription.periodEnd + } + const isCadenceChange = + Boolean(currentPlan.annual) !== Boolean(nextPlan.annual) + if (!isCadenceChange) { + return subscription.periodEnd + } + const isAppliedImmediately = !SubscriptionHelper.shouldPlanChangeAtTermEnd( + currentPlan, + nextPlan, + SubscriptionHelper.isInTrial(subscription.trialPeriodEnd) + ) + if (!isAppliedImmediately) { + return subscription.periodEnd + } + const nextInvoiceDate = new Date() + if (nextPlan.annual) { + nextInvoiceDate.setFullYear(nextInvoiceDate.getFullYear() + 1) + } else { + nextInvoiceDate.setMonth(nextInvoiceDate.getMonth() + 1) + } + return nextInvoiceDate +} + /** * Build a subscription change preview for display purposes * @@ -1263,6 +1307,14 @@ function makeChangePreview( const nextPlan = PlansLocator.findLocalPlanInSettings( futureInvoiceChange.nextPlanCode ) + const currentPlan = PlansLocator.findLocalPlanInSettings( + subscription.planCode + ) + const nextInvoiceDate = _getNextInvoiceDate( + subscription, + currentPlan, + nextPlan + ) return { change: subscriptionChangeDescription, @@ -1274,7 +1326,7 @@ function makeChangePreview( annual: nextPlan?.annual ?? false, }, nextInvoice: { - date: subscription.periodEnd.toISOString(), + date: nextInvoiceDate.toISOString(), plan: { name: getPlanNameForDisplay( nextPlan?.name ?? futureInvoiceChange.nextPlanName, diff --git a/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs b/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs index 449663d1a9..bc7b9afc3d 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs +++ b/services/web/test/unit/src/Subscription/SubscriptionController.test.mjs @@ -1529,6 +1529,146 @@ describe('SubscriptionController', function () { 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