From b987024f9dd5b2bc9be0b3f6dc5b44a59067258e Mon Sep 17 00:00:00 2001 From: nate Date: Tue, 24 Mar 2026 21:47:25 +0400 Subject: [PATCH] feat: add plan stacking --- apps/api/src/db.ts | 1 + apps/api/src/routes/auth.ts | 3 +- apps/api/src/utils/plans.ts | 9 ++ apps/pay/src/db.ts | 3 +- apps/pay/src/monitor.test.ts | 220 +++++++++++++++++++++++++++++++ apps/pay/src/monitor.ts | 149 ++++++++++++++++++--- apps/web/src/db.ts | 1 + apps/web/src/routes/dashboard.ts | 2 +- apps/web/src/views/settings.ejs | 12 +- 9 files changed, 381 insertions(+), 19 deletions(-) create mode 100644 apps/pay/src/monitor.test.ts diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts index a8d49d2..038dab4 100644 --- a/apps/api/src/db.ts +++ b/apps/api/src/db.ts @@ -60,6 +60,7 @@ export async function migrate() { await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`; await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`; + await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_stack JSONB NOT NULL DEFAULT '[]'`; await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`; await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`; diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 429a63a..f3053f4 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -115,7 +115,7 @@ export const account = new Elysia({ prefix: "/account" }) .use(requireAuth) .get("/settings", async ({ accountId }) => { - const [acc] = await sql`SELECT id, email_hash, plan, plan_expires_at, created_at FROM accounts WHERE id = ${accountId}`; + const [acc] = await sql`SELECT id, email_hash, plan, plan_expires_at, plan_stack, created_at FROM accounts WHERE id = ${accountId}`; const keys = await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`; const [{ count: monitorCount }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`; const limits = getPlanLimits(acc.plan); @@ -124,6 +124,7 @@ export const account = new Elysia({ prefix: "/account" }) has_email: !!acc.email_hash, plan: acc.plan, plan_expires_at: acc.plan_expires_at, + plan_stack: acc.plan_stack || [], monitor_count: monitorCount, limits, created_at: acc.created_at, diff --git a/apps/api/src/utils/plans.ts b/apps/api/src/utils/plans.ts index 334955a..c5a8ecd 100644 --- a/apps/api/src/utils/plans.ts +++ b/apps/api/src/utils/plans.ts @@ -31,3 +31,12 @@ export const PRO_MULTIPLIERS = [ export const PRO_MONTHLY_USD = 12; export const LIFETIME_USD = 140; + +// Tier ranking for plan stacking decisions +const PLAN_RANK: Record = { + free: 0, pro: 1, lifetime: 1, pro2x: 2, pro4x: 3, +}; + +export function planTier(plan: string): number { + return PLAN_RANK[plan] ?? 0; +} diff --git a/apps/pay/src/db.ts b/apps/pay/src/db.ts index 0143153..51884f9 100644 --- a/apps/pay/src/db.ts +++ b/apps/pay/src/db.ts @@ -9,8 +9,9 @@ const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@local export default sql; export async function migrate() { - // Plan expiry on accounts (may already exist from API/web migrations) + // Plan columns on accounts (may already exist from API/web migrations) await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`; + await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_stack JSONB NOT NULL DEFAULT '[]'`; await sql` CREATE TABLE IF NOT EXISTS payments ( diff --git a/apps/pay/src/monitor.test.ts b/apps/pay/src/monitor.test.ts new file mode 100644 index 0000000..aaaf0f2 --- /dev/null +++ b/apps/pay/src/monitor.test.ts @@ -0,0 +1,220 @@ +import { test, expect, describe } from "bun:test"; +import { computeApplyPlan, computeExpiry, insertIntoStack } from "./monitor"; + +const day = 86400000; +const now = new Date("2026-03-24T00:00:00Z"); +function daysFromNow(d: number) { return new Date(now.getTime() + d * day); } + +describe("insertIntoStack", () => { + test("inserts into empty stack", () => { + expect(insertIntoStack([], { plan: "pro", remaining_days: 20 })) + .toEqual([{ plan: "pro", remaining_days: 20 }]); + }); + + test("maintains tier-descending order", () => { + const stack = [{ plan: "pro2x", remaining_days: 10 }]; + expect(insertIntoStack(stack, { plan: "pro4x", remaining_days: 5 })) + .toEqual([{ plan: "pro4x", remaining_days: 5 }, { plan: "pro2x", remaining_days: 10 }]); + }); + + test("inserts lower tier after higher", () => { + const stack = [{ plan: "pro2x", remaining_days: 10 }]; + expect(insertIntoStack(stack, { plan: "pro", remaining_days: 30 })) + .toEqual([{ plan: "pro2x", remaining_days: 10 }, { plan: "pro", remaining_days: 30 }]); + }); + + test("merges same plan by adding days", () => { + const stack = [{ plan: "pro", remaining_days: 20 }]; + expect(insertIntoStack(stack, { plan: "pro", remaining_days: 30 })) + .toEqual([{ plan: "pro", remaining_days: 50 }]); + }); + + test("merges same plan — null wins (lifetime)", () => { + const stack = [{ plan: "lifetime", remaining_days: null }]; + expect(insertIntoStack(stack, { plan: "lifetime", remaining_days: null })) + .toEqual([{ plan: "lifetime", remaining_days: null }]); + }); + + test("merges timed into permanent → permanent", () => { + const stack = [{ plan: "pro", remaining_days: 20 }]; + // This shouldn't happen in practice but tests null-wins logic + expect(insertIntoStack(stack, { plan: "pro", remaining_days: null })) + .toEqual([{ plan: "pro", remaining_days: null }]); + }); +}); + +describe("computeApplyPlan", () => { + test("free user buys Pro 1mo", () => { + const acc = { plan: "free", plan_expires_at: null, plan_stack: [] }; + const result = computeApplyPlan(acc, { plan: "pro", months: 1 }, now); + expect(result.plan).toBe("pro"); + expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 30 * day); + expect(result.plan_stack).toEqual([]); + }); + + test("pro user renews same plan", () => { + const expires = daysFromNow(20); + const acc = { plan: "pro", plan_expires_at: expires, plan_stack: [] }; + const result = computeApplyPlan(acc, { plan: "pro", months: 1 }, now); + expect(result.plan).toBe("pro"); + expect(result.plan_expires_at!.getTime()).toBe(expires.getTime() + 30 * day); + expect(result.plan_stack).toEqual([]); + }); + + test("pro (20d left) upgrades to pro2x 1mo", () => { + const acc = { plan: "pro", plan_expires_at: daysFromNow(20), plan_stack: [] }; + const result = computeApplyPlan(acc, { plan: "pro2x", months: 1 }, now); + expect(result.plan).toBe("pro2x"); + expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 30 * day); + expect(result.plan_stack).toEqual([{ plan: "pro", remaining_days: 20 }]); + }); + + test("pro2x (10d left) upgrades to pro4x, existing pro base", () => { + const acc = { + plan: "pro2x", plan_expires_at: daysFromNow(10), + plan_stack: [{ plan: "pro", remaining_days: 20 }] + }; + const result = computeApplyPlan(acc, { plan: "pro4x", months: 1 }, now); + expect(result.plan).toBe("pro4x"); + expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 30 * day); + // pro2x (tier 2) should be before pro (tier 1) in stack + expect(result.plan_stack).toEqual([ + { plan: "pro2x", remaining_days: 10 }, + { plan: "pro", remaining_days: 20 }, + ]); + }); + + test("lifetime user buys pro2x 1mo", () => { + const acc = { plan: "lifetime", plan_expires_at: null, plan_stack: [] }; + const result = computeApplyPlan(acc, { plan: "pro2x", months: 1 }, now); + expect(result.plan).toBe("pro2x"); + expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 30 * day); + expect(result.plan_stack).toEqual([{ plan: "lifetime", remaining_days: null }]); + }); + + test("pro4x (15d left) buys lifetime — goes to stack", () => { + const acc = { plan: "pro4x", plan_expires_at: daysFromNow(15), plan_stack: [] }; + const result = computeApplyPlan(acc, { plan: "lifetime", months: null }, now); + expect(result.plan).toBe("pro4x"); // stays active (higher tier) + expect(result.plan_expires_at!.getTime()).toBe(daysFromNow(15).getTime()); + expect(result.plan_stack).toEqual([{ plan: "lifetime", remaining_days: null }]); + }); + + test("pro2x (10d left) buys pro 1mo with lifetime in stack", () => { + const acc = { + plan: "pro2x", plan_expires_at: daysFromNow(10), + plan_stack: [{ plan: "lifetime", remaining_days: null }] + }; + const result = computeApplyPlan(acc, { plan: "pro", months: 1 }, now); + expect(result.plan).toBe("pro2x"); // stays active + expect(result.plan_stack).toEqual([ + { plan: "pro", remaining_days: 30 }, + { plan: "lifetime", remaining_days: null }, + ]); + }); + + test("expired pro activates new plan without saving expired", () => { + const acc = { plan: "pro", plan_expires_at: daysFromNow(-5), plan_stack: [] }; + const result = computeApplyPlan(acc, { plan: "pro2x", months: 1 }, now); + expect(result.plan).toBe("pro2x"); + expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 30 * day); + expect(result.plan_stack).toEqual([]); + }); + + test("free with existing stack — preserves stack", () => { + const acc = { plan: "free", plan_expires_at: null, plan_stack: [{ plan: "lifetime", remaining_days: null }] }; + const result = computeApplyPlan(acc, { plan: "pro", months: 1 }, now); + expect(result.plan).toBe("pro"); + expect(result.plan_stack).toEqual([{ plan: "lifetime", remaining_days: null }]); + }); + + test("buying same lower tier twice merges days", () => { + const acc = { + plan: "pro2x", plan_expires_at: daysFromNow(20), + plan_stack: [{ plan: "pro", remaining_days: 15 }] + }; + const result = computeApplyPlan(acc, { plan: "pro", months: 1 }, now); + expect(result.plan).toBe("pro2x"); // stays + expect(result.plan_stack).toEqual([{ plan: "pro", remaining_days: 45 }]); // 15 + 30 + }); +}); + +describe("computeExpiry", () => { + test("returns null for non-pro plans", () => { + expect(computeExpiry({ plan: "free", plan_expires_at: null, plan_stack: [] }, now)).toBeNull(); + expect(computeExpiry({ plan: "lifetime", plan_expires_at: null, plan_stack: [] }, now)).toBeNull(); + }); + + test("returns null for non-expired pro", () => { + const acc = { plan: "pro", plan_expires_at: daysFromNow(10), plan_stack: [] }; + expect(computeExpiry(acc, now)).toBeNull(); + }); + + test("expired pro with empty stack → free", () => { + const acc = { plan: "pro", plan_expires_at: daysFromNow(-1), plan_stack: [] }; + const result = computeExpiry(acc, now)!; + expect(result.plan).toBe("free"); + expect(result.plan_expires_at).toBeNull(); + expect(result.plan_stack).toEqual([]); + }); + + test("expired pro with lifetime in stack → promote lifetime", () => { + const acc = { + plan: "pro2x", plan_expires_at: daysFromNow(-1), + plan_stack: [{ plan: "lifetime", remaining_days: null }] + }; + const result = computeExpiry(acc, now)!; + expect(result.plan).toBe("lifetime"); + expect(result.plan_expires_at).toBeNull(); + expect(result.plan_stack).toEqual([]); + }); + + test("expired pro with timed base → promote with computed expiry", () => { + const acc = { + plan: "pro2x", plan_expires_at: daysFromNow(-1), + plan_stack: [{ plan: "pro", remaining_days: 20 }] + }; + const result = computeExpiry(acc, now)!; + expect(result.plan).toBe("pro"); + expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 20 * day); + expect(result.plan_stack).toEqual([]); + }); + + test("expired pro with multi-layer stack → promotes top, keeps rest", () => { + const acc = { + plan: "pro4x", plan_expires_at: daysFromNow(-1), + plan_stack: [ + { plan: "pro2x", remaining_days: 15 }, + { plan: "lifetime", remaining_days: null }, + ] + }; + const result = computeExpiry(acc, now)!; + expect(result.plan).toBe("pro2x"); + expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 15 * day); + expect(result.plan_stack).toEqual([{ plan: "lifetime", remaining_days: null }]); + }); + + test("expired pro skips zero-day layers", () => { + const acc = { + plan: "pro4x", plan_expires_at: daysFromNow(-1), + plan_stack: [ + { plan: "pro2x", remaining_days: 0 }, + { plan: "pro", remaining_days: 10 }, + ] + }; + const result = computeExpiry(acc, now)!; + expect(result.plan).toBe("pro"); + expect(result.plan_expires_at!.getTime()).toBe(now.getTime() + 10 * day); + expect(result.plan_stack).toEqual([]); + }); + + test("expired pro with all zero-day layers → free", () => { + const acc = { + plan: "pro", plan_expires_at: daysFromNow(-1), + plan_stack: [{ plan: "pro2x", remaining_days: 0 }] + }; + const result = computeExpiry(acc, now)!; + expect(result.plan).toBe("free"); + expect(result.plan_stack).toEqual([]); + }); +}); diff --git a/apps/pay/src/monitor.ts b/apps/pay/src/monitor.ts index 522c474..51c16ed 100644 --- a/apps/pay/src/monitor.ts +++ b/apps/pay/src/monitor.ts @@ -220,28 +220,147 @@ export function watchPayment(payment: any) { addressMap.set(payment.address, payment); } -async function applyPlan(payment: any) { - if (payment.plan === "lifetime") { - await sql`UPDATE accounts SET plan = 'lifetime', plan_expires_at = NULL WHERE id = ${payment.account_id}`; - } else { - const [acc] = await sql`SELECT plan, plan_expires_at FROM accounts WHERE id = ${payment.account_id}`; - const now = new Date(); - const expiry = acc.plan_expires_at ? new Date(acc.plan_expires_at) : null; - const samePlan = acc.plan === payment.plan && expiry && expiry > now; - const base = samePlan ? expiry : now; - const newExpiry = new Date(base); - newExpiry.setMonth(newExpiry.getMonth() + payment.months); - await sql`UPDATE accounts SET plan = ${payment.plan}, plan_expires_at = ${newExpiry.toISOString()} WHERE id = ${payment.account_id}`; +// ── Plan stacking logic (pure, testable) ───────────────────────── + +interface StackEntry { plan: string; remaining_days: number | null } +interface AccountState { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] } +interface AccountUpdate { plan: string; plan_expires_at: Date | null; plan_stack: StackEntry[] } + +const PLAN_RANK: Record = { free: 0, pro: 1, lifetime: 1, pro2x: 2, pro4x: 3 }; +function planTier(plan: string): number { return PLAN_RANK[plan] ?? 0; } + +export function insertIntoStack(stack: StackEntry[], entry: StackEntry): StackEntry[] { + const result = stack.slice(); + // Merge if same plan already exists + const existing = result.findIndex(e => e.plan === entry.plan); + if (existing !== -1) { + const old = result[existing]; + if (old.remaining_days === null || entry.remaining_days === null) { + result[existing] = { plan: entry.plan, remaining_days: null }; + } else { + result[existing] = { plan: entry.plan, remaining_days: old.remaining_days + entry.remaining_days }; + } + // Re-sort after merge + result.sort((a, b) => planTier(b.plan) - planTier(a.plan)); + return result; } + // Insert at correct position (tier descending) + const tier = planTier(entry.plan); + let i = 0; + while (i < result.length && planTier(result[i].plan) >= tier) i++; + result.splice(i, 0, entry); + return result; +} + +export function computeApplyPlan( + acc: AccountState, + payment: { plan: string; months: number | null }, + now: Date +): AccountUpdate { + const stack = (acc.plan_stack || []).slice(); + const newPlan = payment.plan; + const newDays = payment.plan === "lifetime" ? null : (payment.months ?? 1) * 30; + + const currentExpiry = acc.plan_expires_at ? new Date(acc.plan_expires_at) : null; + const currentIsActive = acc.plan === "lifetime" + || (acc.plan !== "free" && currentExpiry && currentExpiry > now); + + // No active plan worth saving — just activate the new one + if (!currentIsActive || acc.plan === "free") { + const expiresAt = newDays != null ? new Date(now.getTime() + newDays * 86400000) : null; + return { plan: newPlan, plan_expires_at: expiresAt, plan_stack: stack }; + } + + // Same plan renewal — extend from current expiry + if (newPlan === acc.plan && newDays != null && currentExpiry) { + const extended = new Date(currentExpiry.getTime() + newDays * 86400000); + return { plan: acc.plan, plan_expires_at: extended, plan_stack: stack }; + } + + // Upgrade: new plan takes over, current gets frozen onto stack + if (planTier(newPlan) > planTier(acc.plan)) { + const remainingDays = acc.plan === "lifetime" + ? null + : Math.ceil((currentExpiry!.getTime() - now.getTime()) / 86400000); + const newStack = insertIntoStack(stack, { plan: acc.plan, remaining_days: remainingDays }); + const expiresAt = newDays != null ? new Date(now.getTime() + newDays * 86400000) : null; + return { plan: newPlan, plan_expires_at: expiresAt, plan_stack: newStack }; + } + + // Downgrade/side-grade: purchased plan goes onto stack, current stays active + const newStack = insertIntoStack(stack, { plan: newPlan, remaining_days: newDays }); + return { plan: acc.plan, plan_expires_at: currentExpiry, plan_stack: newStack }; +} + +export function computeExpiry(acc: AccountState, now: Date): AccountUpdate | null { + // Only expire timed pro plans + if (!["pro", "pro2x", "pro4x"].includes(acc.plan)) return null; + if (!acc.plan_expires_at || new Date(acc.plan_expires_at) >= now) return null; + + const stack = (acc.plan_stack || []).slice(); + + // Pop layers until we find a valid one or exhaust the stack + while (stack.length > 0) { + const next = stack.shift()!; + if (next.remaining_days === null) { + // Permanent plan (lifetime) + return { plan: next.plan, plan_expires_at: null, plan_stack: stack }; + } + if (next.remaining_days > 0) { + const expiresAt = new Date(now.getTime() + next.remaining_days * 86400000); + return { plan: next.plan, plan_expires_at: expiresAt, plan_stack: stack }; + } + // 0 days — skip, try next + } + + // Stack exhausted — fall to free + return { plan: "free", plan_expires_at: null, plan_stack: [] }; +} + +// ── DB wrappers ────────────────────────────────────────────────── + +async function applyPlan(payment: any) { + await sql.begin(async (tx) => { + const [acc] = await tx` + SELECT plan, plan_expires_at, plan_stack FROM accounts WHERE id = ${payment.account_id} FOR UPDATE + `; + const update = computeApplyPlan( + { plan: acc.plan, plan_expires_at: acc.plan_expires_at, plan_stack: acc.plan_stack || [] }, + { plan: payment.plan, months: payment.months }, + new Date() + ); + await tx` + UPDATE accounts SET plan = ${update.plan}, + plan_expires_at = ${update.plan_expires_at?.toISOString() ?? null}, + plan_stack = ${JSON.stringify(update.plan_stack)} + WHERE id = ${payment.account_id} + `; + }); console.log(`Payment ${payment.id} activated: ${payment.plan} for account ${payment.account_id}`); } export async function expireProPlans() { - const result = await sql` - UPDATE accounts SET plan = 'free', plan_expires_at = NULL + const expired = await sql` + SELECT id, plan, plan_expires_at, plan_stack FROM accounts WHERE plan IN ('pro', 'pro2x', 'pro4x') AND plan_expires_at IS NOT NULL AND plan_expires_at < now() `; - if (result.count > 0) console.log(`Downgraded ${result.count} expired pro accounts`); + if (expired.length === 0) return; + + const now = new Date(); + for (const acc of expired) { + const update = computeExpiry( + { plan: acc.plan, plan_expires_at: acc.plan_expires_at, plan_stack: acc.plan_stack || [] }, + now + ); + if (!update) continue; + await sql` + UPDATE accounts SET plan = ${update.plan}, + plan_expires_at = ${update.plan_expires_at?.toISOString() ?? null}, + plan_stack = ${JSON.stringify(update.plan_stack)} + WHERE id = ${acc.id} + `; + console.log(`Account ${acc.id}: ${acc.plan} expired → ${update.plan}`); + } } function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); } diff --git a/apps/web/src/db.ts b/apps/web/src/db.ts index d2951bc..8df4ce0 100644 --- a/apps/web/src/db.ts +++ b/apps/web/src/db.ts @@ -57,6 +57,7 @@ export async function migrate() { await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`; await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_expires_at TIMESTAMPTZ`; + await sql`ALTER TABLE accounts ADD COLUMN IF NOT EXISTS plan_stack JSONB NOT NULL DEFAULT '[]'`; await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`; await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`; diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index 097a865..b3ccb1a 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -244,7 +244,7 @@ export const dashboard = new Elysia() const keyId = resolved?.keyId ?? null; if (!accountId) return redirect("/dashboard"); - const [acc] = await sql`SELECT id, email_hash, plan, plan_expires_at, created_at FROM accounts WHERE id = ${accountId}`; + const [acc] = await sql`SELECT id, email_hash, plan, plan_expires_at, plan_stack, created_at FROM accounts WHERE id = ${accountId}`; const isSubKey = !!keyId; const apiKeys = isSubKey ? [] : await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`; const loginKey = isSubKey ? null : (cookie?.pingql_key?.value ?? null); diff --git a/apps/web/src/views/settings.ejs b/apps/web/src/views/settings.ejs index f73423d..3abdf01 100644 --- a/apps/web/src/views/settings.ejs +++ b/apps/web/src/views/settings.ejs @@ -51,8 +51,18 @@ <% } else if ((plan === 'pro' || plan === 'pro2x' || plan === 'pro4x') && it.account.plan_expires_at) { %>
- Pro plan expires <%= new Date(it.account.plan_expires_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) %>. + <%= planLabel %> expires <%= new Date(it.account.plan_expires_at).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) %>. Extend or upgrade to Lifetime + <% const stack = it.account.plan_stack || []; + if (stack.length > 0) { + const planNames = { free: 'Free', pro: 'Pro', pro2x: 'Pro 2x', pro4x: 'Pro 4x', lifetime: 'Lifetime' }; + const parts = stack.map(function(s) { + const name = planNames[s.plan] || s.plan; + return s.remaining_days == null ? name : name + ' (' + s.remaining_days + 'd)'; + }); + %> +
Then: <%= parts.join(' → ') %>
+ <% } %>
<% } %>