From f974f3066286e0c219edef63c907d8917a1b7b41 Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 10:23:19 +0530 Subject: [PATCH 1/6] feat(shared): pricing v2 plans, lifetime and entitlements --- packages/shared/src/plans.ts | 79 ++++++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/plans.ts b/packages/shared/src/plans.ts index ce943d4..490d9ef 100644 --- a/packages/shared/src/plans.ts +++ b/packages/shared/src/plans.ts @@ -1,17 +1,17 @@ export type BillingCycle = "monthly" | "yearly" export type BillingCurrency = "usd" | "inr" -export type PaidTier = "starter" | "builder" +export type PaidTier = "pro" | "team" export const paidPlans = { - starter: { - name: "Starter", - monthly: { usd: 2, inr: 175 }, - yearly: { usd: 20, inr: 1899 }, + pro: { + name: "Pro", + monthly: { usd: 3.99, inr: 299 }, + yearly: { usd: 29, inr: 2499 }, }, - builder: { - name: "Builder", - monthly: { usd: 5, inr: 475 }, - yearly: { usd: 50, inr: 4799 }, + team: { + name: "Team", + monthly: { usd: 18, inr: 1499 }, + yearly: { usd: 179, inr: 14999 }, }, } as const satisfies Record +export const founderPlan = { + name: "Pro Lifetime", + price: { usd: 24, inr: 1999 }, + cap: 100, +} as const + export const tierTunnelLimits: Record = { free: 1, - starter: 2, - builder: 5, + pro: 5, + team: Number.POSITIVE_INFINITY, + founder: 5, enterprise: Number.POSITIVE_INFINITY, } -const PAID_TIERS = new Set(["starter", "builder", "enterprise"]) +const PAID_TIERS = new Set(["pro", "team", "founder", "enterprise"]) // Dodo subscription statuses that still grant paid access. "active" is the // steady state; "trialing" covers trials. A subscription cancelled with @@ -60,6 +67,12 @@ export function effectiveTier( opts?: EffectiveTierOpts, ): string { if (!tier) return "free" + // Legacy normalization: starter/builder were replaced by pro. + // Existing DB rows keep working without a data migration. + if (tier === "starter" || tier === "builder") tier = "pro" + // A one-time lifetime purchase has no subscription status to check — + // it must never fall back to "free". + if (tier === "founder") return "founder" if (!PAID_TIERS.has(tier)) return tier const status = (subscriptionStatus ?? "").toLowerCase() if (ACTIVE_STATUSES.has(status)) return tier @@ -75,3 +88,45 @@ export function effectiveTier( if (status && !KNOWN_STATUSES.has(status) && renewsInFuture) return tier return "free" } + +export type Entitlement = + | "hosted_relay" | "unlimited_connections" | "file_browser" | "split_diff" + | "global_search" | "session_export" | "approval_rules" | "usage_dashboard" + | "extra_themes" | "custom_domain" | "fleet_view" | "audit_log" | "sso" | "priority_support" + +export const entitlements: Record = { + hosted_relay: "pro", + unlimited_connections: "pro", + file_browser: "pro", + split_diff: "pro", + global_search: "pro", + session_export: "pro", + approval_rules: "pro", + usage_dashboard: "pro", + extra_themes: "pro", + custom_domain: "pro", + fleet_view: "team", + audit_log: "team", + sso: "team", + priority_support: "team", +} + +export function tierRank(tier?: string | null): number { + if (!tier) return 0 + // Legacy tiers resolve to pro-level. + if (tier === "starter" || tier === "builder") return 1 + switch (tier) { + case "free": return 0 + case "pro": + case "founder": return 1 + case "team": + case "enterprise": return 2 + default: return 0 + } +} + +export function hasEntitlement(tier: string | null | undefined, e: Entitlement): boolean { + // Legacy tiers resolve to pro-level everywhere. + const normalized = tier === "starter" || tier === "builder" ? "pro" : tier + return tierRank(normalized) >= tierRank(entitlements[e]) +} From 37f73fc44af8637242426d0121b7f6fafc21fc48 Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 10:23:56 +0530 Subject: [PATCH 2/6] feat(mobile): entitlement capabilities and pricing v2 paywall triggers --- apps/mobile/lib/billing.ts | 2 +- apps/mobile/lib/entitlement.ts | 10 ++++- apps/mobile/lib/paywall.ts | 71 +++++++++++++++++++++++++++++----- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/apps/mobile/lib/billing.ts b/apps/mobile/lib/billing.ts index af97c8c..e5eb258 100644 --- a/apps/mobile/lib/billing.ts +++ b/apps/mobile/lib/billing.ts @@ -1,7 +1,7 @@ export async function createBillingCheckout( serverUrl: string, sessionToken: string, - tier: "starter" | "builder", + tier: "pro" | "team", cycle: "monthly" | "yearly" = "monthly", ): Promise { const response = await fetch(`${serverUrl}/api/billing/checkout`, { diff --git a/apps/mobile/lib/entitlement.ts b/apps/mobile/lib/entitlement.ts index fb5e444..5aace7e 100644 --- a/apps/mobile/lib/entitlement.ts +++ b/apps/mobile/lib/entitlement.ts @@ -1,7 +1,13 @@ -const PAID_TIERS = ["starter", "builder"] +import { hasEntitlement, type Entitlement } from "@crosscode/shared" +import { useAuth } from "@/store/auth.store" + +export const PAID_TIERS = ["pro", "team", "founder", "enterprise"] export function isPaidTier(tier?: string | null) { return !!tier && PAID_TIERS.includes(tier) } - +export function can(e: Entitlement, tier?: string | null): boolean { + const t = tier ?? useAuth.getState().user?.tier ?? "free" + return hasEntitlement(t, e) +} diff --git a/apps/mobile/lib/paywall.ts b/apps/mobile/lib/paywall.ts index 113b657..d9ad0cd 100644 --- a/apps/mobile/lib/paywall.ts +++ b/apps/mobile/lib/paywall.ts @@ -1,7 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage" -import { tierTunnelLimits } from "@crosscode/shared" +import { tierTunnelLimits, type Entitlement } from "@crosscode/shared" import { useAuth } from "@/store/auth.store" import { usePaywall } from "@/store/paywall.store" +import { can } from "@/lib/entitlement" export type PaywallTrigger = | "connection_limit" @@ -10,13 +11,33 @@ export type PaywallTrigger = | "session_nudge" | "onboarding" | "manual" + | "file_browser" + | "saved_connections_cap" + | "approval_rules" + | "stable_url" + | "global_search" export type PaywallCopy = { title: string description: string } -const HARD_TRIGGERS: PaywallTrigger[] = ["connection_limit", "tunnel_limit", "manual"] +const HARD_TRIGGERS: PaywallTrigger[] = [ + "connection_limit", + "tunnel_limit", + "manual", + "file_browser", + "saved_connections_cap", +] + +const TRIGGER_ENTITLEMENT: Partial> = { + file_browser: "file_browser", + saved_connections_cap: "unlimited_connections", + connection_limit: "unlimited_connections", + approval_rules: "approval_rules", + stable_url: "hosted_relay", + global_search: "global_search", +} const SHOWS_KEY = "crosscode-paywall-shows" const DISMISS_KEY = "crosscode-paywall-dismisses" @@ -58,32 +79,57 @@ export function getPaywallCopy(trigger: PaywallTrigger): PaywallCopy { case "connection_limit": return { title: "You've hit your connection limit", - description: "Free includes 1 tunnel. Upgrade to run more projects in parallel.", + description: "Free includes 1 tunnel. Upgrade to Pro to run more projects in parallel.", + } + case "saved_connections_cap": + return { + title: "Run every machine you own", + description: "Pro unlocks unlimited saved connections — every machine, one phone.", } case "tunnel_limit": return { title: "Tunnel limit reached", - description: "Your plan is at capacity. Upgrade for more active tunnels.", + description: "Your plan is at capacity. Upgrade to Pro for 5 active tunnels.", + } + case "file_browser": + return { + title: "Browse the whole repo from your phone", + description: "The file browser and viewer are Pro — explore any repo, anywhere.", + } + case "approval_rules": + return { + title: "Let safe commands run themselves", + description: "Pro approval rules auto-approve the safe stuff and ping you for the rest.", + } + case "stable_url": + return { + title: "Stop re-scanning the QR", + description: "Pro's hosted relay gives you a stable URL — connect once, stay connected.", + } + case "global_search": + return { + title: "Find that fix from last week", + description: "Pro searches across every session at once — never lose work again.", } case "session_nudge": return { title: "Power through more sessions", - description: "Keep every project live at once with extra tunnels.", + description: "Keep every project live at once with Pro's extra tunnels.", } case "onboarding": return { title: "Do more with CrossCode", - description: "Start free with 1 tunnel, upgrade whenever you need more.", + description: "Start free with 1 tunnel, upgrade to Pro whenever you need more.", } case "usage_nudge": return { title: "Running out of room?", - description: "You're using your full tunnel quota. Upgrade to add another project.", + description: "You're using your full tunnel quota. Upgrade to Pro to add another project.", } default: return { - title: "Upgrade your plan", - description: "Add more tunnels and run multiple projects side by side.", + title: "Upgrade to Pro", + description: "Hosted relay, 5 tunnels, and power features for your agent.", } } } @@ -104,7 +150,12 @@ export async function shouldShowPaywall(trigger: PaywallTrigger): Promise { const tier = useAuth.getState().user?.tier ?? "free" - if (tier === "builder" || tier === "enterprise") return false + const needed = TRIGGER_ENTITLEMENT[trigger] + if (needed && can(needed, tier)) return false + if (!needed && (tier === "pro" || tier === "founder" || tier === "team" || tier === "enterprise")) { + // Legacy catch-all: users with Pro-level+ never see generic nudges. + if (trigger === "usage_nudge" || trigger === "session_nudge" || trigger === "onboarding") return false + } if (!opts?.force && !(await shouldShowPaywall(trigger))) return false await pushStamp(SHOWS_KEY, Date.now()) usePaywall.getState().show(trigger) From 4baa642c9eee71877b63351be3bc557d6dfdb4f8 Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 10:25:43 +0530 Subject: [PATCH 3/6] feat(mobile): pro paywall sheet and file browser gates --- apps/mobile/app/(tabs)/index.tsx | 2 +- apps/mobile/app/connect.tsx | 2 +- .../app/project/[projectId]/browser.tsx | 42 +++++++++++++++ .../mobile/app/project/[projectId]/viewer.tsx | 28 +++++++++- apps/mobile/components/PaywallSheet.tsx | 52 ++++++++++++------- apps/mobile/components/TunnelUsageCard.tsx | 4 +- 6 files changed, 107 insertions(+), 23 deletions(-) diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index 6c5aff6..e391374 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -253,7 +253,7 @@ export default function HomeScreen() { const handleAddConnection = React.useCallback(() => { if (atLimit) { - void requestPaywall("connection_limit") + void requestPaywall("saved_connections_cap") return } router.push("/new-connection") diff --git a/apps/mobile/app/connect.tsx b/apps/mobile/app/connect.tsx index eb6c0be..f80692c 100644 --- a/apps/mobile/app/connect.tsx +++ b/apps/mobile/app/connect.tsx @@ -85,7 +85,7 @@ export default function Connect() { } const tier = useAuth.getState().user?.tier ?? "free" if (isAtTunnelLimit(tier, connections.length)) { - void requestPaywall("connection_limit") + void requestPaywall("saved_connections_cap") return } const id = addConnection({ diff --git a/apps/mobile/app/project/[projectId]/browser.tsx b/apps/mobile/app/project/[projectId]/browser.tsx index 22b3ec9..168efad 100644 --- a/apps/mobile/app/project/[projectId]/browser.tsx +++ b/apps/mobile/app/project/[projectId]/browser.tsx @@ -12,6 +12,9 @@ import XIcon from "lucide-react-native/dist/esm/icons/x" import { Button } from "@/components/ui/button" import { Text } from "@/components/ui/text" import { THEME } from "@/lib/theme" +import { hasEntitlement } from "@crosscode/shared" +import { useAuth } from "@/store/auth.store" +import { requestPaywall } from "@/lib/paywall" import { useProjectConnection } from "@/lib/project-connection" import { useProjects } from "@/store/projects.store" import { @@ -77,6 +80,14 @@ export default function BrowserPage() { const { connection } = useProjectConnection(projectId) const connected = !!(connection?.url && connection?.token) + const tier = useAuth((s) => s.user?.tier ?? "free") + const allowed = hasEntitlement(tier, "file_browser") + + React.useEffect(() => { + if (connected && !allowed) { + void requestPaywall("file_browser") + } + }, [connected, allowed]) const project = useProjects((s) => s.projects.find((p) => p.id === projectId)) const rootLabel = useMemo(() => { if (project?.name) return project.name @@ -285,6 +296,37 @@ export default function BrowserPage() { ) } + if (!allowed) { + return ( + + + + Files + + + + File browser is Pro + + + Browse the whole repo from your phone with Pro. + + + + + ) + } + return ( s.user?.tier ?? "free") + const allowed = hasEntitlement(tier, "file_browser") + + useEffect(() => { + if (connection?.url && connection?.token && !allowed) { + void requestPaywall("file_browser") + } + }, [connection?.url, connection?.token, allowed]) + const [content, setContent] = useState(null) const [truncated, setTruncated] = useState(false) const [loading, setLoading] = useState(true) @@ -107,7 +119,21 @@ export default function ViewerPage() { )} - {!connection || !projectId ? null : loading ? ( + {!connection || !projectId ? null : !allowed ? ( + + File viewer is Pro + + View any file from your phone with Pro. + + + + ) : loading ? ( Loading file... diff --git a/apps/mobile/components/PaywallSheet.tsx b/apps/mobile/components/PaywallSheet.tsx index eddeeff..b81c136 100644 --- a/apps/mobile/components/PaywallSheet.tsx +++ b/apps/mobile/components/PaywallSheet.tsx @@ -5,7 +5,7 @@ import { useColorScheme } from "nativewind" import Zap from "lucide-react-native/dist/esm/icons/zap" import X from "lucide-react-native/dist/esm/icons/x" import Check from "lucide-react-native/dist/esm/icons/check" -import { paidPlans, tierTunnelLimits, type BillingCycle, type PaidTier } from "@crosscode/shared" +import { paidPlans, tierTunnelLimits, type BillingCurrency, type BillingCycle, type PaidTier } from "@crosscode/shared" import { Text } from "@/components/ui/text" import { Button } from "@/components/ui/button" import { THEME } from "@/lib/theme" @@ -15,7 +15,26 @@ import { usePaywall } from "@/store/paywall.store" import { createBillingCheckout } from "@/lib/billing" import { getPaywallCopy, recordPaywallDismiss, refreshTier } from "@/lib/paywall" -const TIERS: PaidTier[] = ["starter", "builder"] +const TIERS: PaidTier[] = ["pro"] + +function detectCurrency(): BillingCurrency { + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "" + if (tz.includes("Kolkata") || tz.includes("Calcutta")) return "inr" + } catch {} + return "usd" +} + +function discountPercent(tier: PaidTier, currency: BillingCurrency): number { + const plan = paidPlans[tier] + return Math.round((1 - plan.yearly[currency] / (plan.monthly[currency] * 12)) * 100) +} + +function formatPrice(tier: PaidTier, cycle: BillingCycle, currency: BillingCurrency): string { + const plan = paidPlans[tier] + const amount = cycle === "monthly" ? plan.monthly[currency] : plan.yearly[currency] + return `${currency === "inr" ? "₹" : "$"}${amount.toLocaleString(currency === "inr" ? "en-IN" : "en-US")}` +} export function PaywallSheet() { const { colorScheme } = useColorScheme() @@ -27,6 +46,7 @@ export function PaywallSheet() { const serverUrl = useAuth((s) => s.serverUrl) const sessionToken = useAuth((s) => s.sessionToken) const [cycle, setCycle] = React.useState("monthly") + const [currency] = React.useState(detectCurrency) const [pendingTier, setPendingTier] = React.useState(null) const [error, setError] = React.useState(null) @@ -118,7 +138,7 @@ export function PaywallSheet() { className="text-sm font-medium capitalize" style={{ color: selected ? THEME[theme].foreground : THEME[theme].mutedForeground }} > - {c === "yearly" ? "Yearly · save ~17%" : "Monthly"} + {c === "yearly" ? `Yearly · save ${discountPercent("pro", currency)}%` : "Monthly"} ) @@ -128,26 +148,22 @@ export function PaywallSheet() { {TIERS.map((tier) => { const plan = paidPlans[tier] - const price = cycle === "monthly" ? plan.monthly.usd : plan.yearly.usd + const price = formatPrice(tier, cycle, currency) const per = cycle === "monthly" ? "/mo" : "/yr" - const isCurrent = currentTier === tier - const isPopular = tier === "builder" + const isCurrent = currentTier === tier || currentTier === "founder" return ( {plan.name} - {isPopular ? ( - - Most tunnels - - ) : null} + + Reliability + power + {isCurrent ? ( Current @@ -155,27 +171,27 @@ export function PaywallSheet() { ) : null} - ${price} + {price} {per} - {tierTunnelLimits[tier]} active tunnel{tierTunnelLimits[tier] === 1 ? "" : "s"} · everything in Free + {tierTunnelLimits[tier]} active tunnels · hosted relay + power features diff --git a/apps/mobile/components/TunnelUsageCard.tsx b/apps/mobile/components/TunnelUsageCard.tsx index 61378ba..b7b9894 100644 --- a/apps/mobile/components/TunnelUsageCard.tsx +++ b/apps/mobile/components/TunnelUsageCard.tsx @@ -169,8 +169,8 @@ export function TunnelUsageCard() { > {stats.totalClients >= 1 - ? "At your Free limit — upgrade for more tunnels" - : "Need more tunnels? Upgrade"} + ? "At your Free limit — Pro gives 5 tunnels + hosted relay" + : "Need more tunnels? Upgrade to Pro"} )} From 188c6d1ff3fc1b87fae89ef14c71337e72600806 Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 10:27:10 +0530 Subject: [PATCH 4/6] feat(web): pricing v2 page, dashboard and lifetime count --- .../app/api/billing/founder-count/route.ts | 19 ++ apps/web/src/app/dashboard/page.tsx | 41 +++- apps/web/src/app/pricing/page.tsx | 220 ++++++++++-------- 3 files changed, 183 insertions(+), 97 deletions(-) create mode 100644 apps/web/src/app/api/billing/founder-count/route.ts diff --git a/apps/web/src/app/api/billing/founder-count/route.ts b/apps/web/src/app/api/billing/founder-count/route.ts new file mode 100644 index 0000000..db8cd09 --- /dev/null +++ b/apps/web/src/app/api/billing/founder-count/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server" +import { count, eq } from "drizzle-orm" +import { db } from "@/lib/db" +import { user } from "@/lib/db/schema" +import { founderPlan } from "@crosscode/shared" +import { checkRateLimit, rateLimitedResponse } from "@/lib/rate-limit" + +export async function GET(req: NextRequest) { + const rl = await checkRateLimit(req, "publicRead") + if (!rl.success) return rateLimitedResponse(rl) + + const rows = await db.select({ value: count() }).from(user).where(eq(user.tier, "founder")) + const sold = rows[0]?.value ?? 0 + return NextResponse.json({ + count: sold, + cap: founderPlan.cap, + remaining: Math.max(0, founderPlan.cap - sold), + }) +} diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index a090360..2151ee9 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -28,6 +28,7 @@ export default function DashboardPage() { const [deviceName, setDeviceName] = useState(null) const [portalLoading, setPortalLoading] = useState(false) const [billingError, setBillingError] = useState("") + const [founderCount, setFounderCount] = useState<{ count: number; cap: number; remaining: number } | null>(null) const pollRef = useRef(null) useEffect(() => { @@ -40,6 +41,12 @@ export default function DashboardPage() { setUser(data.user) const billingResponse = await fetch("/api/billing/subscription") if (billingResponse.ok) setSubscription(await billingResponse.json()) + fetch("/api/billing/founder-count") + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (d && typeof d.count === "number") setFounderCount(d) + }) + .catch(() => {}) setLoading(false) } checkAuth() @@ -70,7 +77,7 @@ export default function DashboardPage() { router.push("/login") } - const openCheckout = async (tier: "starter" | "builder") => { + const openCheckout = async (tier: "pro" | "team") => { const response = await fetch("/api/billing/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -80,6 +87,22 @@ export default function DashboardPage() { if (response.ok && data.checkoutUrl) window.location.assign(data.checkoutUrl) } + const openFounderCheckout = async () => { + const response = await fetch("/api/billing/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tier: "founder" }), + }) + const data = await response.json() + if (response.ok && data.checkoutUrl) window.location.assign(data.checkoutUrl) + } + + const displayTier = (t?: string | null) => { + if (t === "starter" || t === "builder") return "Pro (grandfathered)" + if (t === "founder") return "Pro Lifetime" + return t || "free" + } + const openPortal = async () => { setPortalLoading(true) setBillingError("") @@ -220,7 +243,7 @@ export default function DashboardPage() {
Tier: - {subscription.tier || "free"} + {displayTier(subscription.tier)}
@@ -233,7 +256,7 @@ export default function DashboardPage() {
Plan: - {subscription.tier || "free"} + {displayTier(subscription.tier)}
Status: @@ -251,8 +274,16 @@ export default function DashboardPage() {
{(!subscription.tier || subscription.tier === "free") ? ( <> - - + + ) : ( ))}
@@ -230,7 +237,7 @@ export default function PricingPage() {
-
+
{tiers.map((tier) => ( {tier.tier ? `${effectiveCurrency === "inr" ? "₹" : "$"}${paidPlans[tier.tier][cycle][effectiveCurrency]}` - : tier.name === "Free" - ? effectiveCurrency === "inr" ? "₹0" : "$0" - : "Custom"} + : effectiveCurrency === "inr" ? "₹0" : "$0"} - {tier.name !== "Enterprise" && /{cycle === "monthly" ? "mo" : "yr"}} + /{cycle === "monthly" ? "mo" : "yr"}
{tier.tier && ( <> {effectiveCurrency === "inr" && (

- {`≈ $${paidPlans[tier.tier][cycle].usd}/yr`} + {`≈ $${paidPlans[tier.tier][cycle].usd}/${cycle === "monthly" ? "mo" : "yr"}`}

)} {cycle === "yearly" && ( @@ -326,7 +331,37 @@ export default function PricingPage() { ))}
-
+
+ + +
+
+ Pro Lifetime — one-time purchase +

+ Pay once, keep Pro forever — hosted relay + power features for as long as CrossCode operates, subject to fair use. + {founderCount ? ` ${founderCount.remaining} of ${founderCount.cap} left.` : ` Limited to ${founderPlan.cap}.`} +

+
+
+
{founderPrice}
+
one-time
+
+
+
+ + + +
+
+ +

Compare plans

@@ -334,16 +369,14 @@ export default function PricingPage() { - - - + {comparisonFeatures.map((section) => ( - @@ -351,7 +384,7 @@ export default function PricingPage() { {row.values.map((value, i) => ( - ))} @@ -362,6 +395,9 @@ export default function PricingPage() {
Feature FreeStarterBuilderEnterprisePro
+ {section.category}
{row.label} + {value}
+

+ Pro Lifetime unlocks everything in Pro, forever. Grandfathered Starter/Builder subscribers now show as Pro. +

From 2a019412b5b8f24bcd6b0e06deec07bcb90d7522 Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 10:28:10 +0530 Subject: [PATCH 5/6] feat(web): pro and lifetime checkout with founder-safe webhooks --- apps/web/.env.example | 14 +++++-- .../web/src/app/api/billing/checkout/route.ts | 38 ++++++++++++++++--- apps/web/src/app/api/webhooks/dodo/route.ts | 28 +++++++++++++- apps/web/src/lib/dodo.ts | 18 +++++++-- 4 files changed, 83 insertions(+), 15 deletions(-) diff --git a/apps/web/.env.example b/apps/web/.env.example index 4a7574e..59c905e 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -25,10 +25,16 @@ SANITY_API_READ_TOKEN= DODO_PAYMENTS_API_KEY= DODO_PAYMENTS_WEBHOOK_KEY= DODO_PAYMENTS_ENVIRONMENT=live_mode -DODO_PRODUCT_STARTER_MONTHLY= -DODO_PRODUCT_STARTER_YEARLY= -DODO_PRODUCT_BUILDER_MONTHLY= -DODO_PRODUCT_BUILDER_YEARLY= +DODO_PRODUCT_PRO_MONTHLY= +DODO_PRODUCT_PRO_YEARLY= +DODO_PRODUCT_TEAM_MONTHLY= +DODO_PRODUCT_TEAM_YEARLY= +DODO_PRODUCT_FOUNDER_LIFETIME= +# Legacy (existing starter/builder subscriptions only, grandfathered to Pro): +# DODO_PRODUCT_STARTER_MONTHLY= +# DODO_PRODUCT_STARTER_YEARLY= +# DODO_PRODUCT_BUILDER_MONTHLY= +# DODO_PRODUCT_BUILDER_YEARLY= # Abuse protection: Upstash Redis (per-IP sliding-window rate limits) UPSTASH_REDIS_REST_URL= diff --git a/apps/web/src/app/api/billing/checkout/route.ts b/apps/web/src/app/api/billing/checkout/route.ts index 47a41b4..88a7e81 100644 --- a/apps/web/src/app/api/billing/checkout/route.ts +++ b/apps/web/src/app/api/billing/checkout/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from "next/server" -import { eq } from "drizzle-orm" +import { count, eq } from "drizzle-orm" import { getBillingUser } from "@/lib/billing-auth" -import { appUrl, getDodo, getProductId } from "@/lib/dodo" +import { appUrl, getDodo, getFounderProductId, getProductId } from "@/lib/dodo" import { db } from "@/lib/db" import { user } from "@/lib/db/schema" -import type { BillingCurrency, BillingCycle, PaidTier } from "@crosscode/shared" +import { founderPlan, type BillingCurrency, type BillingCycle } from "@crosscode/shared" import { checkRateLimit, rateLimitedResponse } from "@/lib/rate-limit" export async function POST(req: NextRequest) { @@ -19,10 +19,13 @@ export async function POST(req: NextRequest) { if (!userRl.success) return rateLimitedResponse(userRl) const body = await req.json() as { tier?: string; cycle?: string; currency?: string } - const tier = body.tier as PaidTier + const tier = body.tier const cycle = body.cycle as BillingCycle const currency = body.currency as BillingCurrency | undefined - if (!["starter", "builder"].includes(tier) || !["monthly", "yearly"].includes(cycle)) { + if (tier !== "pro" && tier !== "team" && tier !== "founder") { + return NextResponse.json({ error: "Invalid plan" }, { status: 400 }) + } + if ((tier === "pro" || tier === "team") && !["monthly", "yearly"].includes(cycle)) { return NextResponse.json({ error: "Invalid plan" }, { status: 400 }) } @@ -47,7 +50,30 @@ export async function POST(req: NextRequest) { } } - const productId = getProductId(tier, cycle) + if (tier === "founder") { + const soldRows = await db.select({ value: count() }).from(user).where(eq(user.tier, "founder")) + const sold = soldRows[0]?.value ?? 0 + if (sold >= founderPlan.cap) { + return NextResponse.json({ error: "Pro Lifetime is sold out" }, { status: 400 }) + } + const productId = getFounderProductId() + const checkout = await dodo.checkoutSessions.create({ + product_cart: [{ product_id: productId, quantity: 1 }], + customer: { customer_id: customerId }, + allowed_payment_method_types: ["upi_collect", "upi_intent", "credit", "debit"], + ...(currency === "inr" ? { billing_currency: "INR", billing_address: { country: "IN" } } : {}), + return_url: appUrl("/billing/success"), + cancel_url: appUrl("/billing/cancelled"), + metadata: { + user_id: currentUser.id, + tier: "founder", + }, + feature_flags: { redirect_immediately: true }, + }) + return NextResponse.json({ checkoutUrl: checkout.checkout_url, sessionId: checkout.session_id }) + } + + const productId = getProductId(tier as "pro" | "team", cycle) const checkout = await dodo.checkoutSessions.create({ product_cart: [{ product_id: productId, quantity: 1 }], customer: { customer_id: customerId }, diff --git a/apps/web/src/app/api/webhooks/dodo/route.ts b/apps/web/src/app/api/webhooks/dodo/route.ts index 86d51da..12dc0f1 100644 --- a/apps/web/src/app/api/webhooks/dodo/route.ts +++ b/apps/web/src/app/api/webhooks/dodo/route.ts @@ -54,6 +54,28 @@ export async function POST(req: NextRequest) { // effectiveTier() saw "updated" instead of "active" and the CLI // reported "free" while the dashboard (raw tier column) still showed // the paid plan (issue #129). + // + // Exception: a one-time Pro Lifetime purchase never creates a + // subscription — it arrives as a payment event. It must persist as + // paid and never be downgraded by ended logic. + if (tier === "founder") { + const founderStatus = + (typeof data.status === "string" && data.status) || + (typeof data.subscription_status === "string" && data.subscription_status) || + currentUser.subscriptionStatus || + "lifetime" + await db.update(user).set({ + dodoCustomerId: customerId || currentUser.dodoCustomerId, + dodoSubscriptionId: subscriptionId || currentUser.dodoSubscriptionId, + subscriptionProductId: productId || currentUser.subscriptionProductId, + subscriptionStatus: founderStatus, + subscriptionRenewsAt: currentUser.subscriptionRenewsAt, + subscriptionCancelAtPeriodEnd: false, + tier: "founder", + updatedAt: new Date(), + }).where(eq(user.id, currentUser.id)) + return NextResponse.json({ received: true }) + } if (!event.type.startsWith("subscription.")) return NextResponse.json({ received: true }) // The real subscription status lives in the payload, not the event @@ -71,6 +93,10 @@ export async function POST(req: NextRequest) { // A cancelled subscription still grants access until the period ends. const inGracePeriod = !!cancelAtPeriodEnd && !!renewsAt && renewsAt.getTime() > Date.now() const ended = ["cancelled", "canceled", "expired", "terminated"].includes(status) && !inGracePeriod + // A lifetime purchase must never be overwritten to free by ended logic. + const nextTier = ended + ? (currentUser.tier === "founder" || tier === "founder" ? "founder" : "free") + : tier || currentUser.tier await db.update(user).set({ dodoCustomerId: customerId || currentUser.dodoCustomerId, dodoSubscriptionId: subscriptionId || currentUser.dodoSubscriptionId, @@ -78,7 +104,7 @@ export async function POST(req: NextRequest) { subscriptionStatus: payloadStatus ? status : currentUser.subscriptionStatus, subscriptionRenewsAt: renewsAt, subscriptionCancelAtPeriodEnd: Boolean(cancelAtPeriodEnd), - tier: ended ? "free" : tier || currentUser.tier, + tier: nextTier, updatedAt: new Date(), }).where(eq(user.id, currentUser.id)) diff --git a/apps/web/src/lib/dodo.ts b/apps/web/src/lib/dodo.ts index e150928..84096c7 100644 --- a/apps/web/src/lib/dodo.ts +++ b/apps/web/src/lib/dodo.ts @@ -14,16 +14,26 @@ export function getDodo() { }) } -export function getProductId(tier: "starter" | "builder", cycle: "monthly" | "yearly") { +export function getProductId(tier: "pro" | "team", cycle: "monthly" | "yearly") { const key = `DODO_PRODUCT_${tier.toUpperCase()}_${cycle.toUpperCase()}` as const const productId = process.env[key] if (!productId) throw new Error(`${key} is not configured`) return productId } -export function tierFromProductId(productId: string): "starter" | "builder" | null { - if (productId === process.env.DODO_PRODUCT_STARTER_MONTHLY || productId === process.env.DODO_PRODUCT_STARTER_YEARLY) return "starter" - if (productId === process.env.DODO_PRODUCT_BUILDER_MONTHLY || productId === process.env.DODO_PRODUCT_BUILDER_YEARLY) return "builder" +export function getFounderProductId() { + const productId = process.env.DODO_PRODUCT_FOUNDER_LIFETIME + if (!productId) throw new Error("DODO_PRODUCT_FOUNDER_LIFETIME is not configured") + return productId +} + +export function tierFromProductId(productId: string): "pro" | "team" | "founder" | null { + if (productId === process.env.DODO_PRODUCT_PRO_MONTHLY || productId === process.env.DODO_PRODUCT_PRO_YEARLY) return "pro" + if (productId === process.env.DODO_PRODUCT_TEAM_MONTHLY || productId === process.env.DODO_PRODUCT_TEAM_YEARLY) return "team" + if (productId === process.env.DODO_PRODUCT_FOUNDER_LIFETIME) return "founder" + // Legacy mapping: starter/builder subscriptions grandfather into pro. + if (productId === process.env.DODO_PRODUCT_STARTER_MONTHLY || productId === process.env.DODO_PRODUCT_STARTER_YEARLY) return "pro" + if (productId === process.env.DODO_PRODUCT_BUILDER_MONTHLY || productId === process.env.DODO_PRODUCT_BUILDER_YEARLY) return "pro" return null } From 0360c395b9e9558d7e78e6e4a63f72b5829c1ed4 Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 10:33:15 +0530 Subject: [PATCH 6/6] fix(pricing-v2): resolve typecheck errors in gates and webhooks --- apps/mobile/app/project/[projectId]/browser.tsx | 2 +- apps/mobile/components/message-item.tsx | 1 + apps/web/src/app/api/webhooks/dodo/route.ts | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/app/project/[projectId]/browser.tsx b/apps/mobile/app/project/[projectId]/browser.tsx index 168efad..4f7aaee 100644 --- a/apps/mobile/app/project/[projectId]/browser.tsx +++ b/apps/mobile/app/project/[projectId]/browser.tsx @@ -83,7 +83,7 @@ export default function BrowserPage() { const tier = useAuth((s) => s.user?.tier ?? "free") const allowed = hasEntitlement(tier, "file_browser") - React.useEffect(() => { + useEffect(() => { if (connected && !allowed) { void requestPaywall("file_browser") } diff --git a/apps/mobile/components/message-item.tsx b/apps/mobile/components/message-item.tsx index aeddd22..70a5d68 100644 --- a/apps/mobile/components/message-item.tsx +++ b/apps/mobile/components/message-item.tsx @@ -26,6 +26,7 @@ import { PermissionRequest } from "@/store/permissions.store" import { revertMessage, forkSession } from "@/lib/sessions" import { useProjectConnection } from "@/lib/project-connection" import { useSessions } from "@/store/sessions.store" +import { useConnections } from "@/store/connection.store" import { useModels } from "@/store/models.store" import { useSelectiveCopy } from "@/store/selective-copy.store" diff --git a/apps/web/src/app/api/webhooks/dodo/route.ts b/apps/web/src/app/api/webhooks/dodo/route.ts index 12dc0f1..d19fe85 100644 --- a/apps/web/src/app/api/webhooks/dodo/route.ts +++ b/apps/web/src/app/api/webhooks/dodo/route.ts @@ -36,7 +36,8 @@ export async function POST(req: NextRequest) { const customerEmail = typeof customer?.email === "string" ? customer.email : null const subscriptionId = typeof data.subscription_id === "string" ? data.subscription_id : null const productId = typeof data.product_id === "string" ? data.product_id : null - const tier = productId ? tierFromProductId(productId) : null + const productTier: string | null = productId ? tierFromProductId(productId) : null + const tier = productTier const currentUser = userId ? await db.query.user.findFirst({ where: eq(user.id, userId) })