فهرست منبع

[web] Fix preview next-invoice date for cadence-change upgrades (#33697)

* [web] Fix preview next-invoice date for cadence-change upgrades

When upgrading from a monthly plan to an annual plan (or vice versa) the
user pays for a full new-cadence term today, so the next payment is one
new-term-length from now — not the current cycle's period end. Previously
we always echoed subscription.periodEnd in the preview, which surfaced
the stale current-cycle date and misled the user into thinking they'd
be charged again ~25 days later.

makeChangePreview now compares the current and next plans' annual flag:
on a cadence flip it returns now + 1 year or now + 1 month; otherwise it
keeps the existing behaviour.

Closes #33283.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Format

* Fix next invoice date using priceincents

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
GitOrigin-RevId: 05b660ecb518c04b60e88f2ddc7531733245bdde
Antoine Clausse 4 ماه پیش
والد
کامیت
09f03381fd

+ 53 - 1
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,

+ 140 - 0
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