Initial import: property management SaaS + security hardening + admin dashboard
Property Management Network — Next.js 16 (App Router), Better Auth, Drizzle ORM over PostgreSQL, Stripe, OpenAI, Resend. Includes: - Security hardening: access-control/IDOR fixes, TLS-by-default DB layer, constant-time cron auth, strict security headers, atomic AI quota gating, HTML/email output encoding, demo-backdoor disabled in production. - Superadmin dashboard at /admin (overview/MRR, server-paginated users with ban/impersonate/plan/delete, billing, platform activity + admin audit log, AI usage, system health) via the Better Auth admin plugin. - Seed/migration utility scripts under scripts/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { and, eq, sql } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { profiles, properties, tenants } from "@/lib/db/schema"
|
||||
import { getSessionUser } from "@/lib/session"
|
||||
import { CheckoutButton } from "@/components/forms/checkout-button"
|
||||
import { PortalButton } from "@/components/forms/portal-button"
|
||||
import { getPlanLabel, PLAN_LIMITS } from "@/lib/stripe/plans"
|
||||
import { Check } from "lucide-react"
|
||||
import type { Plan } from "@/types"
|
||||
|
||||
export const metadata = { title: "Billing" }
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
key: "starter" as Plan,
|
||||
name: "Starter",
|
||||
price: "$0",
|
||||
interval: "forever",
|
||||
description: "For landlords just getting started",
|
||||
features: ["1 property", "3 tenants", "Maintenance tracking", "Rent tracker"],
|
||||
cta: "Current plan",
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
key: "pro" as Plan,
|
||||
name: "Pro",
|
||||
price: "$29",
|
||||
interval: "/month",
|
||||
description: "For active landlords growing their portfolio",
|
||||
features: ["10 properties", "Unlimited tenants", "50 AI calls/month", "5GB storage", "Email notifications", "Stripe rent collection"],
|
||||
cta: "Upgrade to Pro",
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
key: "landlord" as Plan,
|
||||
name: "Landlord",
|
||||
price: "$59",
|
||||
interval: "/month",
|
||||
description: "For property managers at scale",
|
||||
features: ["Unlimited properties", "Unlimited tenants", "200 AI calls/month", "25GB storage", "Team access", "White-label"],
|
||||
cta: "Upgrade to Landlord",
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
key: "lifetime" as Plan,
|
||||
name: "Lifetime",
|
||||
price: "$199",
|
||||
interval: "one-time",
|
||||
description: "Everything in Landlord, forever",
|
||||
features: ["Everything in Landlord", "Lifetime updates", "Priority support", "Flippa-ready asset"],
|
||||
cta: "Get Lifetime Deal",
|
||||
highlight: false,
|
||||
},
|
||||
]
|
||||
|
||||
export default async function BillingPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ success?: string; canceled?: string }>
|
||||
}) {
|
||||
const user = await getSessionUser()
|
||||
if (!user) redirect("/login")
|
||||
|
||||
const profile = await db.query.profiles.findFirst({
|
||||
where: eq(profiles.id, user.id),
|
||||
columns: {
|
||||
plan: true,
|
||||
subscription_status: true,
|
||||
plan_expires_at: true,
|
||||
stripe_customer_id: true,
|
||||
stripe_subscription_id: true,
|
||||
},
|
||||
})
|
||||
|
||||
const params = await searchParams
|
||||
const currentPlan = (profile?.plan ?? "starter") as Plan
|
||||
const hasStripeAccount = !!profile?.stripe_customer_id
|
||||
const limits = PLAN_LIMITS[currentPlan]
|
||||
|
||||
const [[{ count: propertiesUsed }], [{ count: tenantsUsed }]] = await Promise.all([
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(properties)
|
||||
.where(eq(properties.user_id, user.id)),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(tenants)
|
||||
.where(and(eq(tenants.user_id, user.id), eq(tenants.status, "active"))),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-8">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Billing</h2>
|
||||
<p className="text-sm text-white/40">Manage your subscription and plan</p>
|
||||
</div>
|
||||
|
||||
{params.success && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-5 py-4 text-sm text-emerald-400">
|
||||
Payment successful! Your plan has been upgraded.
|
||||
</div>
|
||||
)}
|
||||
{params.canceled && (
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/10 px-5 py-4 text-sm text-amber-400">
|
||||
Checkout canceled — no charge was made.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current plan */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-white/40">Current Plan</p>
|
||||
<p className="mt-1 text-xl font-bold text-white">{getPlanLabel(currentPlan)}</p>
|
||||
{profile?.subscription_status && (
|
||||
<p className="mt-0.5 text-xs text-white/40 capitalize">Status: {profile.subscription_status}</p>
|
||||
)}
|
||||
</div>
|
||||
{hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && (
|
||||
<PortalButton />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plans grid */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{PLANS.map((plan) => {
|
||||
const isCurrent = currentPlan === plan.key
|
||||
const isDowngrade = (
|
||||
currentPlan === "landlord" && (plan.key === "pro" || plan.key === "starter") ||
|
||||
currentPlan === "lifetime"
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.key}
|
||||
className={`relative flex flex-col rounded-xl border p-5 ${
|
||||
plan.highlight
|
||||
? "border-indigo-500/40 bg-indigo-600/5"
|
||||
: "border-white/[0.06] bg-[#16161f]"
|
||||
}`}
|
||||
>
|
||||
{plan.highlight && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="rounded-full bg-indigo-600 px-3 py-0.5 text-xs font-semibold text-white">
|
||||
Most Popular
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{plan.name}</p>
|
||||
<div className="mt-1 flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold text-white">{plan.price}</span>
|
||||
<span className="text-xs text-white/40">{plan.interval}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-white/40">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 flex-1 space-y-2">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-xs text-white/60">
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-5">
|
||||
{isCurrent ? (
|
||||
<div className="w-full rounded-lg border border-white/10 py-2 text-center text-xs font-medium text-white/40">
|
||||
Current Plan
|
||||
</div>
|
||||
) : plan.key === "starter" || isDowngrade ? (
|
||||
<div className="w-full rounded-lg border border-white/10 py-2 text-center text-xs font-medium text-white/30">
|
||||
{plan.key === "starter" ? "Free" : "Downgrade via portal"}
|
||||
</div>
|
||||
) : (
|
||||
<CheckoutButton plan={plan.key} label={plan.cta} highlight={plan.highlight} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Usage overview */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-5">
|
||||
<h3 className="text-sm font-semibold text-white">Usage</h3>
|
||||
{[
|
||||
{
|
||||
label: "Properties",
|
||||
used: propertiesUsed ?? 0,
|
||||
max: limits.maxProperties,
|
||||
},
|
||||
{
|
||||
label: "Active Tenants",
|
||||
used: tenantsUsed ?? 0,
|
||||
max: limits.maxTenants,
|
||||
},
|
||||
].map(({ label, used, max }) => {
|
||||
const unlimited = max === Infinity
|
||||
const pct = unlimited ? 0 : Math.min(100, Math.round((used / max) * 100))
|
||||
const nearLimit = !unlimited && pct >= 80
|
||||
return (
|
||||
<div key={label}>
|
||||
<div className="mb-1.5 flex items-center justify-between text-xs">
|
||||
<span className="text-white/60">{label}</span>
|
||||
<span className={nearLimit ? "text-amber-400 font-medium" : "text-white/40"}>
|
||||
{used} / {unlimited ? "Unlimited" : max}
|
||||
</span>
|
||||
</div>
|
||||
{!unlimited && (
|
||||
<div className="h-1.5 w-full rounded-full bg-white/[0.06]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
pct >= 100 ? "bg-red-500" : pct >= 80 ? "bg-amber-500" : "bg-indigo-500"
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="grid grid-cols-2 gap-4 pt-1 sm:grid-cols-2 border-t border-white/[0.06]">
|
||||
<div>
|
||||
<p className="text-xs text-white/40">AI Calls / mo</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{limits.maxAiCalls === 0 ? "Not included" : limits.maxAiCalls}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/40">Storage</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{limits.maxStorageMB >= 1024 ? `${limits.maxStorageMB / 1024}GB` : `${limits.maxStorageMB}MB`}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user