Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions frontend/src/pages/billing/BillingPage.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1223,3 +1223,161 @@ 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: `<div class="plan-card" :class="{ 'is-disabled': disabled }">{{ note }}</div>`,
};
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");
});
});

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));
});
});

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
});
});
88 changes: 80 additions & 8 deletions frontend/src/pages/billing/BillingPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -834,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();
Expand Down Expand Up @@ -883,12 +943,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),
});
Expand Down
Loading