From dc87482b85a981506aed6c1fa4c9aa00fc3cea72 Mon Sep 17 00:00:00 2001 From: Navin-S-R Date: Sat, 5 Sep 2026 22:48:51 +0530 Subject: [PATCH 1/3] fix(billing): trial-aware upgrade-card copy + near-renewal disable The billing page's upgrade cards now reflect the control-plane upgrade rules: - a TRIAL customer sees "your free trial moves to this plan, nothing charged now" on same-cycle targets, and cross-cycle (Annual) targets are disabled with "available once your free trial converts" (the server refuses them); - when within 24h of renewal the cards are disabled ("renews within a day") to mirror the server's UpgradeTooCloseToRenewal refusal, so no dead-end CTA. Reads account.is_trial + the new account.upgrade_locked_near_renewal flag. Paid customers keep the "prorated difference" copy. Verified live in-browser across all four card states. --- .../src/pages/billing/BillingPage.spec.js | 83 +++++++++++++++++++ frontend/src/pages/billing/BillingPage.vue | 35 +++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/billing/BillingPage.spec.js b/frontend/src/pages/billing/BillingPage.spec.js index 80141e852..9ccef99ac 100644 --- a/frontend/src/pages/billing/BillingPage.spec.js +++ b/frontend/src/pages/billing/BillingPage.spec.js @@ -1223,3 +1223,86 @@ describe("the paid notice speaks for the BILLING surface, not the signup wizard" expect(window.location.assign).toHaveBeenCalledWith("/jarvis/"); }); }); + +describe("upgrade card copy: trial vs paid", () => { + // A PlanCard stub that renders its note + a disabled marker, so we can assert the + // per-plan copy the page computes (the default STUBS stub PlanCard opaquely). + const PlanCardNote = { + name: "PlanCard", + props: ["plan", "actionLabel", "note", "disabled", "loading", "current", "badge"], + template: `
{{ note }}
`, + }; + async function mountWith(account) { + api.getAccount.mockResolvedValue(account); + const wrapper = mount(BillingPage, { + global: { stubs: { ...STUBS, PlanCard: PlanCardNote } }, + }); + await flushPromises(); + return wrapper; + } + + it("tells a PAID customer they pay the prorated difference", async () => { + const wrapper = await mountWith( + baseAccount({ + subscription_status: "Active", + days_remaining: 20, + is_trial: 0, + upgrade_plans: [ + { name: "max", plan_name: "Max", price_inr: 200, billing_cycle: "Monthly" }, + ], + }) + ); + const card = findByText(wrapper, ".plan-card", "prorated difference"); + expect(card).toBeTruthy(); + expect(card.classes()).not.toContain("is-disabled"); + }); + + it("tells a TRIAL customer nothing is charged now and the trial moves", async () => { + const wrapper = await mountWith( + baseAccount({ + subscription_status: "Active", + days_remaining: 10, + is_trial: 1, + upgrade_plans: [ + { name: "max", plan_name: "Max", price_inr: 200, billing_cycle: "Monthly" }, + ], + }) + ); + const card = findByText(wrapper, ".plan-card", "Nothing is charged now"); + expect(card).toBeTruthy(); + expect(card.classes()).not.toContain("is-disabled"); + }); + + it("disables a cross-cycle (Annual) upgrade during a trial and says why", async () => { + const wrapper = await mountWith( + baseAccount({ + subscription_status: "Active", + days_remaining: 10, + is_trial: 1, + upgrade_plans: [ + { name: "ann", plan_name: "Annual", price_inr: 900, billing_cycle: "Annual" }, + ], + }) + ); + const card = findByText(wrapper, ".plan-card", "converts to a paid plan"); + expect(card).toBeTruthy(); + expect(card.classes()).toContain("is-disabled"); + }); + + it("disables upgrade cards near renewal (R2) with a why note", async () => { + const wrapper = await mountWith( + baseAccount({ + subscription_status: "Active", + days_remaining: 1, + is_trial: 0, + upgrade_locked_near_renewal: true, + upgrade_plans: [ + { name: "max", plan_name: "Max", price_inr: 200, billing_cycle: "Monthly" }, + ], + }) + ); + const card = findByText(wrapper, ".plan-card", "renews within a day"); + expect(card).toBeTruthy(); + expect(card.classes()).toContain("is-disabled"); + }); +}); diff --git a/frontend/src/pages/billing/BillingPage.vue b/frontend/src/pages/billing/BillingPage.vue index ad25f351e..d135e5a63 100644 --- a/frontend/src/pages/billing/BillingPage.vue +++ b/frontend/src/pages/billing/BillingPage.vue @@ -182,8 +182,8 @@ :key="'up-' + p.name" :plan="p" action-label="Upgrade" - note="You pay only the prorated difference for the days left in this period." - :disabled="changesBlocked" + :note="upgradeNote(p)" + :disabled="changesBlocked || upgradeDisabled(p)" :loading="busy === 'up:' + p.name" @action="doUpgrade" /> @@ -528,6 +528,37 @@ const currentPlan = computed(() => { }); const upgradePlans = computed(() => account.value.upgrade_plans || []); const downgradePlans = computed(() => account.value.downgrade_plans || []); +const isTrial = computed(() => !!account.value.is_trial); +// The server refuses an upgrade within 24h of renewal (UpgradeTooCloseToRenewal), so mirror +// that here: disable the upgrade cards with a reason instead of offering a button that errors. +const upgradeNearRenewal = computed(() => !!account.value.upgrade_locked_near_renewal); +// Upgrade copy differs on a TRIAL: nothing is charged now (the trial just moves to +// the new plan, first charge at trial-end), vs a paid customer who pays the prorated +// difference for the days left. A trial can only switch SAME-cycle (the server refuses +// a cross-cycle/Annual target with TrialCrossCycleUnsupported), so those cards are +// disabled during a trial with a note that says why, rather than erroring on click. +function upgradeCrossCycle(p) { + return !!( + currentPlan.value && + p.billing_cycle && + p.billing_cycle !== currentPlan.value.billing_cycle + ); +} +function upgradeDisabled(p) { + return upgradeNearRenewal.value || (isTrial.value && upgradeCrossCycle(p)); +} +function upgradeNote(p) { + if (upgradeNearRenewal.value) { + return "Your plan renews within a day - you can upgrade right after it renews."; + } + if (isTrial.value) { + if (upgradeCrossCycle(p)) { + return "Available once your free trial converts to a paid plan."; + } + return "Your free trial moves to this plan. Nothing is charged now; you're billed at the new price when your trial ends."; + } + return "You pay only the prorated difference for the days left in this period."; +} const cancelling = computed(() => !!account.value.cancel_at_period_end); const scheduledDowngrade = computed(() => !!account.value.scheduled_plan); // The lapsed cohort (Expired/Cancelled/Past-Due-with-no-days-left): a plan From 244977d4311c92b939208c3de346dabf47ca79b2 Mon Sep 17 00:00:00 2001 From: Navin-S-R Date: Sat, 5 Sep 2026 23:06:52 +0530 Subject: [PATCH 2/3] fix(billing): trial-aware upgrade CONFIRMATION dialog (P2 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upgrade cards promised a trial switch charges nothing, but doUpgrade's confirm dialog still read "Charged now…" with a "Pay ₹0" CTA. Branch the confirm copy on the preview `mode`: a trial_switch shows "No charge now - your free trial moves to this plan; you'll be billed when your trial ends" with a "Switch plan" CTA; a paid upgrade keeps the prorated "Pay ₹…" charge. --- .../src/pages/billing/BillingPage.spec.js | 46 +++++++++++++++++++ frontend/src/pages/billing/BillingPage.vue | 24 +++++++--- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/billing/BillingPage.spec.js b/frontend/src/pages/billing/BillingPage.spec.js index 9ccef99ac..1b172b91b 100644 --- a/frontend/src/pages/billing/BillingPage.spec.js +++ b/frontend/src/pages/billing/BillingPage.spec.js @@ -1306,3 +1306,49 @@ describe("upgrade card copy: trial vs paid", () => { expect(card.classes()).toContain("is-disabled"); }); }); + +describe("upgrade confirmation dialog: trial vs paid", () => { + const growth = { + name: "growth", + plan_name: "Growth", + price_inr: 500, + billing_cycle: "Monthly", + }; + + it("a TRIAL switch confirms with NO charge and a 'Switch plan' CTA, not 'Pay ₹0'", async () => { + const wrapper = await mountPage( + baseAccount({ + subscription_status: "Active", + is_trial: 1, + days_remaining: 10, + upgrade_plans: [growth], + }) + ); + // preview for a trial-switch: 0 charge, mode marker + the target price for the copy. + api.previewUpgrade.mockResolvedValue({ + mode: "trial_switch", + prorated_inr: 0, + target_total_inr: 2950, + }); + await wrapper.vm.doUpgrade(growth); + expect(wrapper.vm.pending.confirmLabel).toBe("Switch plan"); + expect(wrapper.vm.pending.amount).toBe(""); + expect(wrapper.vm.pending.message).toContain("No charge now"); + expect(wrapper.vm.pending.message).toContain(inrLabel(2950)); + }); + + it("a PAID upgrade still confirms with the prorated 'Pay ₹…' charge", async () => { + const wrapper = await mountPage( + baseAccount({ + subscription_status: "Active", + is_trial: 0, + days_remaining: 10, + upgrade_plans: [growth], + }) + ); + api.previewUpgrade.mockResolvedValue({ mode: "delta", prorated_inr: 590 }); + await wrapper.vm.doUpgrade(growth); + expect(wrapper.vm.pending.confirmLabel).toBe(`Pay ${inrLabel(590)}`); + expect(wrapper.vm.pending.amount).toBe(inrLabel(590)); + }); +}); diff --git a/frontend/src/pages/billing/BillingPage.vue b/frontend/src/pages/billing/BillingPage.vue index d135e5a63..899b055c1 100644 --- a/frontend/src/pages/billing/BillingPage.vue +++ b/frontend/src/pages/billing/BillingPage.vue @@ -914,12 +914,24 @@ async function doUpgrade(plan) { // (days-left * GST-inclusive daily rate) and can land on paise, same as // total_inr below - inr()'s bare toLocaleString would show a stray // one-decimal amount instead of the exact figure charged. - describe: (d) => ({ - amount: inrExact(d.prorated_inr), - message: - "Charged now for the days left in your current billing period. Your new plan starts immediately.", - confirmLabel: `Pay ${inrExact(d.prorated_inr)}`, - }), + // A TRIAL switch takes no money today (the trial just moves to the new plan, first + // charge at trial-end), so the confirm must match the card's "nothing charged now" + // promise - no "Pay ₹0". A paid upgrade leads with the prorated charge as before. + describe: (d) => + d.mode === "trial_switch" + ? { + amount: "", + message: `No charge now - your free trial moves to this plan. You'll be billed ${inrExact( + d.target_total_inr + )} when your trial ends.`, + confirmLabel: "Switch plan", + } + : { + amount: inrExact(d.prorated_inr), + message: + "Charged now for the days left in your current billing period. Your new plan starts immediately.", + confirmLabel: `Pay ${inrExact(d.prorated_inr)}`, + }, start: () => api.startUpgrade(plan.name), retry: () => doUpgrade(plan), }); From 02be3486ce0ebbba0c9ab221521b576d2f08407c Mon Sep 17 00:00:00 2001 From: Navin-S-R Date: Sat, 5 Sep 2026 23:33:23 +0530 Subject: [PATCH 3/3] fix(billing): refresh the near-renewal upgrade lock (P2 review) upgrade_locked_near_renewal is a server-computed, time-based flag baked into the account snapshot; current_period_end is not re-read as it passes, so a tab left open across renewal kept every upgrade card disabled until a manual reload. Re-read server truth when the tab becomes visible again, and poll (5-min) while the lock is active so a continuously-open tab also clears on its own right after renewal. Both stop once the lock is gone. --- .../src/pages/billing/BillingPage.spec.js | 29 +++++++++++++++++++ frontend/src/pages/billing/BillingPage.vue | 29 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/frontend/src/pages/billing/BillingPage.spec.js b/frontend/src/pages/billing/BillingPage.spec.js index 1b172b91b..63b2f5822 100644 --- a/frontend/src/pages/billing/BillingPage.spec.js +++ b/frontend/src/pages/billing/BillingPage.spec.js @@ -1352,3 +1352,32 @@ describe("upgrade confirmation dialog: trial vs paid", () => { expect(wrapper.vm.pending.amount).toBe(inrLabel(590)); }); }); + +describe("upgrade near-renewal lock freshness (R2)", () => { + it("re-reads the account when the tab becomes visible, clearing a stale lock", async () => { + const locked = baseAccount({ + subscription_status: "Active", + is_trial: 0, + days_remaining: 1, + upgrade_locked_near_renewal: true, + upgrade_plans: [ + { name: "max", plan_name: "Max", price_inr: 200, billing_cycle: "Monthly" }, + ], + }); + const wrapper = await mountPage(locked); + expect(wrapper.vm.upgradeNearRenewal).toBe(true); + + // After renewal the server returns an UNLOCKED account. + api.getAccount.mockResolvedValue({ ...locked, upgrade_locked_near_renewal: false }); + const before = api.getAccount.mock.calls.length; + Object.defineProperty(document, "visibilityState", { + value: "visible", + configurable: true, + }); + document.dispatchEvent(new Event("visibilitychange")); + await flushPromises(); + + expect(api.getAccount.mock.calls.length).toBeGreaterThan(before); // re-read, not stuck on the snapshot + expect(wrapper.vm.upgradeNearRenewal).toBe(false); // lock cleared client-side + }); +}); diff --git a/frontend/src/pages/billing/BillingPage.vue b/frontend/src/pages/billing/BillingPage.vue index 899b055c1..e30de0496 100644 --- a/frontend/src/pages/billing/BillingPage.vue +++ b/frontend/src/pages/billing/BillingPage.vue @@ -865,6 +865,35 @@ onMounted(() => { window.addEventListener("pageshow", onPageShow); }); onBeforeUnmount(() => window.removeEventListener("pageshow", onPageShow)); +// R2 upgrade-lock freshness (review): upgrade_locked_near_renewal is a server-computed, TIME-BASED +// flag baked into the account snapshot. current_period_end is not re-read as it passes, so a tab left +// open across renewal would keep every upgrade card disabled even after the server lock expired - +// "upgrade right after it renews" would need a manual reload. Re-read server truth when the tab becomes +// visible again (the user returning), and while the lock is active poll for it to expire so a +// continuously-open tab also clears on its own. Both stop once the lock is gone. +let nearRenewalPoll = null; +function stopNearRenewalPoll() { + if (nearRenewalPoll) { + clearInterval(nearRenewalPoll); + nearRenewalPoll = null; + } +} +function onVisible() { + if (document.visibilityState === "visible") loadAccount(); +} +watch( + upgradeNearRenewal, + (locked) => { + stopNearRenewalPoll(); + if (locked) nearRenewalPoll = setInterval(loadAccount, 5 * 60 * 1000); + }, + { immediate: true } +); +onMounted(() => document.addEventListener("visibilitychange", onVisible)); +onBeforeUnmount(() => { + document.removeEventListener("visibilitychange", onVisible); + stopNearRenewalPoll(); +}); // Invoices + billing details load on mount, independent of the plan read above. onMounted(() => { loadInvoices();