feat: add plan stacking
This commit is contained in:
parent
7b8f693710
commit
b987024f9d
|
|
@ -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)`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)); }
|
||||
|
|
|
|||
|
|
@ -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)`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue