diff --git a/apps/pay/src/monitor.ts b/apps/pay/src/monitor.ts index a5baeaa..8f936e4 100644 --- a/apps/pay/src/monitor.ts +++ b/apps/pay/src/monitor.ts @@ -239,10 +239,10 @@ export function computeApplyPlan( ): AccountUpdate { const stack = (acc.plan_stack || []).slice(); const newPlan = payment.plan; - const newDays = payment.plan === "lifetime" ? null : (payment.months ?? 1) * 30; + const newDays = payment.plan.startsWith("lifetime") ? null : (payment.months ?? 1) * 30; const currentExpiry = acc.plan_expires_at ? new Date(acc.plan_expires_at) : null; - const currentIsActive = acc.plan === "lifetime" + const currentIsActive = acc.plan.startsWith("lifetime") || (acc.plan !== "free" && currentExpiry && currentExpiry > now); if (!currentIsActive || acc.plan === "free") { @@ -256,7 +256,7 @@ export function computeApplyPlan( } if (planTier(newPlan) > planTier(acc.plan)) { - const remainingDays = acc.plan === "lifetime" + const remainingDays = acc.plan.startsWith("lifetime") ? null : Math.ceil((currentExpiry!.getTime() - now.getTime()) / 86400000); const newStack = insertIntoStack(stack, { plan: acc.plan, remaining_days: remainingDays }); diff --git a/apps/pay/src/receipt.ts b/apps/pay/src/receipt.ts index 0487892..602b672 100644 --- a/apps/pay/src/receipt.ts +++ b/apps/pay/src/receipt.ts @@ -18,10 +18,10 @@ export async function generateReceipt(paymentId: number): Promise { ? new Date(payment.paid_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }) : "-"; const createdDate = new Date(payment.created_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }); - const planNames: Record = { pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime" }; + const planNames: Record = { pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime", lifetime2x: "Lifetime 2x", lifetime4x: "Lifetime 4x" }; const planName = planNames[payment.plan] || payment.plan; - const planLabel = payment.plan === "lifetime" - ? "Lifetime" + const planLabel = payment.plan.startsWith("lifetime") + ? planName : `${planName} × ${payment.months} month${payment.months > 1 ? "s" : ""}`; const txRows = txs.map((tx: any) => { diff --git a/apps/pay/src/routes.ts b/apps/pay/src/routes.ts index f29c7a4..861e1ef 100644 --- a/apps/pay/src/routes.ts +++ b/apps/pay/src/routes.ts @@ -2,7 +2,7 @@ import { Elysia, t } from "elysia"; import sql from "./db"; import { derive } from "./address"; import { getExchangeRates, getAvailableCoins, fetchQrBase64 } from "./freedom"; -import { PLAN_PRICING as PLANS, COINS } from "../../shared/plans"; +import { PLAN_PRICING as PLANS, COINS, planTier } from "../../shared/plans"; import { generateReceipt } from "./receipt"; import { watchPayment } from "./monitor"; import { resolveKey as sharedResolveKey, extractAuthKey } from "../../shared/auth"; @@ -45,11 +45,13 @@ export const routes = new Elysia() const { plan, months, coin } = body; - if (plan === "lifetime") { + if (plan.startsWith("lifetime")) { const [acc] = await sql`SELECT plan, plan_stack FROM accounts WHERE id = ${accountId}`; const stack = typeof acc.plan_stack === "string" ? JSON.parse(acc.plan_stack) : (acc.plan_stack || []); - const hasLifetime = acc.plan === "lifetime" || stack.some((s: any) => s.plan === "lifetime"); - if (hasLifetime) { set.status = 400; return { error: "You already have a lifetime plan" }; } + const buyingTier = planTier(plan); + const ownedLifetimes = [acc.plan, ...stack.map((s: any) => s.plan)].filter((p: string) => p.startsWith("lifetime")); + const maxOwnedTier = Math.max(0, ...ownedLifetimes.map((p: string) => planTier(p))); + if (maxOwnedTier >= buyingTier) { set.status = 400; return { error: "You already have this lifetime tier or higher" }; } } if (!COINS[coin]) { set.status = 400; return { error: `Unknown coin: ${coin}` }; } @@ -60,9 +62,9 @@ export const routes = new Elysia() if (!planDef) { set.status = 400; return { error: `Unknown plan: ${plan}` }; } let amountUsd = planDef.priceUsd ?? (planDef.monthlyUsd! * (months ?? 1)); - if (plan === "lifetime" && planDef.priceUsd) { + if (plan.startsWith("lifetime") && planDef.priceUsd) { const [{ total }] = await sql`SELECT COALESCE(SUM(amount_usd), 0)::numeric as total FROM payments WHERE account_id = ${accountId} AND status = 'paid'`; - const credit = Math.min(Number(total), planDef.priceUsd * 0.75); + const credit = Math.min(Number(total), planDef.priceUsd * 0.90); amountUsd = Math.max(amountUsd - credit, 1); } const rates = await getExchangeRates(); @@ -87,7 +89,7 @@ export const routes = new Elysia() const [payment] = await sql` INSERT INTO payments (account_id, plan, months, amount_usd, coin, amount_crypto, address, derivation_index, expires_at) - VALUES (${accountId}, ${plan}, ${plan === "lifetime" ? null : months ?? 1}, ${amountUsd}, ${coin}, ${amountCrypto}, ${address}, ${next_index}, ${expiresAt.toISOString()}) + VALUES (${accountId}, ${plan}, ${plan.startsWith("lifetime") ? null : months ?? 1}, ${amountUsd}, ${coin}, ${amountCrypto}, ${address}, ${next_index}, ${expiresAt.toISOString()}) RETURNING * `; diff --git a/apps/shared/plans.ts b/apps/shared/plans.ts index 9c645d5..1197017 100644 --- a/apps/shared/plans.ts +++ b/apps/shared/plans.ts @@ -1,4 +1,4 @@ -export type Plan = "free" | "pro" | "pro2x" | "pro4x" | "lifetime"; +export type Plan = "free" | "pro" | "pro2x" | "pro4x" | "lifetime" | "lifetime2x" | "lifetime4x"; export interface PlanLimits { maxMonitors: number; @@ -7,11 +7,13 @@ export interface PlanLimits { } const PLAN_LIMITS: Record = { - free: { maxMonitors: 10, minIntervalS: 30, maxRegions: 1 }, - pro: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 }, - pro2x: { maxMonitors: 400, minIntervalS: 5, maxRegions: 99 }, - pro4x: { maxMonitors: 800, minIntervalS: 5, maxRegions: 99 }, - lifetime: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 }, + free: { maxMonitors: 10, minIntervalS: 30, maxRegions: 1 }, + pro: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 }, + pro2x: { maxMonitors: 400, minIntervalS: 5, maxRegions: 99 }, + pro4x: { maxMonitors: 800, minIntervalS: 5, maxRegions: 99 }, + lifetime: { maxMonitors: 200, minIntervalS: 5, maxRegions: 99 }, + lifetime2x: { maxMonitors: 400, minIntervalS: 5, maxRegions: 99 }, + lifetime4x: { maxMonitors: 800, minIntervalS: 5, maxRegions: 99 }, }; export function getPlanLimits(plan: string): PlanLimits { @@ -19,17 +21,20 @@ export function getPlanLimits(plan: string): PlanLimits { } export const PLAN_LABELS: Record = { - free: "Free", pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", lifetime: "Lifetime", + free: "Free", pro: "Pro", pro2x: "Pro 2x", pro4x: "Pro 4x", + lifetime: "Lifetime", lifetime2x: "Lifetime 2x", lifetime4x: "Lifetime 4x", }; export const PRO_MONTHLY_USD = 12; export const LIFETIME_USD = 140; export const PLAN_PRICING: Record = { - pro: { label: "Pro", monthlyUsd: PRO_MONTHLY_USD }, - pro2x: { label: "Pro 2x", monthlyUsd: PRO_MONTHLY_USD * 2 }, - pro4x: { label: "Pro 4x", monthlyUsd: PRO_MONTHLY_USD * 4 }, - lifetime: { label: "Lifetime", priceUsd: LIFETIME_USD }, + pro: { label: "Pro", monthlyUsd: PRO_MONTHLY_USD }, + pro2x: { label: "Pro 2x", monthlyUsd: PRO_MONTHLY_USD * 2 }, + pro4x: { label: "Pro 4x", monthlyUsd: PRO_MONTHLY_USD * 4 }, + lifetime: { label: "Lifetime", priceUsd: LIFETIME_USD }, + lifetime2x: { label: "Lifetime 2x", priceUsd: LIFETIME_USD * 2 }, + lifetime4x: { label: "Lifetime 4x", priceUsd: LIFETIME_USD * 4 }, }; export const COINS: Record = { @@ -42,7 +47,7 @@ export const COINS: Record = { - free: 0, pro: 1, lifetime: 1, pro2x: 2, pro4x: 3, + free: 0, pro: 1, lifetime: 1, pro2x: 2, lifetime2x: 2, pro4x: 3, lifetime4x: 3, }; export function planTier(plan: string): number { diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index 8c78480..c217d53 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -4,7 +4,7 @@ import { resolve } from "path"; import { resolveKey } from "./auth"; import sql from "../db"; import { sparklineFromPings, pickBestRegion } from "../utils/sparkline"; -import { PLAN_LABELS, REGION_COLORS, REGION_LABELS, REGIONS } from "../../../shared/plans"; +import { PLAN_LABELS, REGION_COLORS, REGION_LABELS, REGIONS, planTier } from "../../../shared/plans"; async function hashFile(path: string): Promise { const bytes = await Bun.file(path).bytes(); @@ -319,8 +319,10 @@ export const dashboard = new Elysia() if (!resolved?.accountId) return redirect("/dashboard"); const [acc] = await sql`SELECT plan, plan_expires_at, plan_stack FROM accounts WHERE id = ${resolved.accountId}`; const stack = typeof acc.plan_stack === "string" ? JSON.parse(acc.plan_stack) : (acc.plan_stack || []); - const hasLifetime = acc.plan === "lifetime" || stack.some((s: any) => s.plan === "lifetime"); - if (acc.plan === "lifetime" && stack.length === 0) return redirect("/dashboard/settings"); + const allPlans = [acc.plan, ...stack.map((s: any) => s.plan)]; + const ownedLifetimes = allPlans.filter((p: string) => p.startsWith("lifetime")); + const maxLifetimeTier = Math.max(0, ...ownedLifetimes.map((p: string) => planTier(p))); + if (maxLifetimeTier >= 3) return redirect("/dashboard/settings"); // has lifetime4x, nothing to upgrade const [{ total_spent }] = await sql`SELECT COALESCE(SUM(amount_usd), 0)::numeric as total_spent FROM payments WHERE account_id = ${resolved.accountId} AND status = 'paid'`; @@ -332,7 +334,7 @@ export const dashboard = new Elysia() coins = data.coins || []; } catch {} - return html("checkout", { nav: "settings", account: acc, payApi, invoiceId: null, coins, invoice: null, totalSpent: Number(total_spent), hasLifetime }); + return html("checkout", { nav: "settings", account: acc, payApi, invoiceId: null, coins, invoice: null, totalSpent: Number(total_spent), maxLifetimeTier }); }) .get("/dashboard/checkout/:id", async ({ cookie, headers, params }) => { diff --git a/apps/web/src/views/checkout.ejs b/apps/web/src/views/checkout.ejs index 8629a26..66c8d7c 100644 --- a/apps/web/src/views/checkout.ejs +++ b/apps/web/src/views/checkout.ejs @@ -7,15 +7,16 @@ const invoice = it.invoice; const payApi = it.payApi || ''; const totalSpent = it.totalSpent || 0; - const hasLifetime = it.hasLifetime || false; + const maxLifetimeTier = it.maxLifetimeTier || 0; const lifetimeBase = 140; - const lifetimeDiscount = hasLifetime ? 0 : Math.min(totalSpent, lifetimeBase * 0.75); - const lifetimePrice = lifetimeBase - lifetimeDiscount; + const lifetimePrices = { lifetime: lifetimeBase, lifetime2x: lifetimeBase * 2, lifetime4x: lifetimeBase * 4 }; + const lifetimeCredit = Math.min(totalSpent, lifetimeBase * 0.90); %>