feat: add plan stacking

This commit is contained in:
nate 2026-03-24 21:47:25 +04:00
parent 7b8f693710
commit b987024f9d
9 changed files with 381 additions and 19 deletions

View File

@ -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)`;

View File

@ -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,

View File

@ -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<string, number> = {
free: 0, pro: 1, lifetime: 1, pro2x: 2, pro4x: 3,
};
export function planTier(plan: string): number {
return PLAN_RANK[plan] ?? 0;
}

View File

@ -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 (

View File

@ -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([]);
});
});

View File

@ -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<string, number> = { 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)); }

View File

@ -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)`;

View File

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

View File

@ -51,8 +51,18 @@
</div>
<% } else if ((plan === 'pro' || plan === 'pro2x' || plan === 'pro4x') && it.account.plan_expires_at) { %>
<div class="mt-4 pt-4 border-t divider text-xs text-gray-500">
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' }) %>.
<a href="/dashboard/checkout" class="text-blue-400 hover:text-blue-300 ml-1">Extend or upgrade to Lifetime</a>
<% 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)';
});
%>
<div class="mt-1 text-gray-600">Then: <%= parts.join(' → ') %></div>
<% } %>
</div>
<% } %>
</section>