diff --git a/.env.example b/.env.example
index d15e863..e628ba3 100644
--- a/.env.example
+++ b/.env.example
@@ -126,3 +126,10 @@ GEOCODER_USER_AGENT=PropertyManagementNetwork/1.0 (https://propertymanagement.ne
# Leave both blank to disable the captcha (auth forms still work).
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
+
+# === SECRETS AT REST ===
+# AES-256-GCM key for the OAuth tokens stored for QuickBooks / Xero / DocuSign /
+# Dropbox Sign. If unset, the key is derived from BETTER_AUTH_SECRET — which
+# means rotating BETTER_AUTH_SECRET would make every stored token undecryptable.
+# Set a dedicated value in production so the two can rotate independently.
+ACCOUNTING_ENCRYPTION_KEY=replace-with-a-random-string
diff --git a/.env.production.example b/.env.production.example
index f33a8d4..1142c8e 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -135,5 +135,14 @@ CRON_SECRET=replace-with-a-random-string
# Create a widget at https://dash.cloudflare.com/?to=/:account/turnstile
# NEXT_PUBLIC_TURNSTILE_SITE_KEY is inlined at build time — also set it as a
# Build Variable in Coolify. Leave both blank to disable the captcha.
+# NOTE: in production a blank TURNSTILE_SECRET_KEY now FAILS CLOSED — auth
+# forms are rejected rather than silently losing bot protection.
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
+
+# === SECRETS AT REST ===
+# AES-256-GCM key for the OAuth tokens stored for QuickBooks / Xero / DocuSign /
+# Dropbox Sign. If unset, the key is derived from BETTER_AUTH_SECRET — which
+# means rotating BETTER_AUTH_SECRET would make every stored token undecryptable.
+# Set a dedicated value in production so the two can rotate independently.
+ACCOUNTING_ENCRYPTION_KEY=replace-with-a-random-string
diff --git a/.gitignore b/.gitignore
index 41be56f..1ad5bc1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,3 +49,9 @@ next-env.d.ts
DOCS/
.env*.local
+
+# playwright
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..643577d
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,9 @@
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/app/(admin)/admin/error.tsx b/app/(admin)/admin/error.tsx
new file mode 100644
index 0000000..1a35d51
--- /dev/null
+++ b/app/(admin)/admin/error.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import { useEffect } from "react"
+import Link from "next/link"
+import { AlertTriangle, RefreshCw, ArrowLeft } from "lucide-react"
+
+/**
+ * Error boundary for the admin surface. Without this, a failure in any admin
+ * page (a Stripe call, an aggregate query) fell through to app/global-error.tsx,
+ * which replaces the whole document and drops the admin chrome — leaving no way
+ * back except editing the URL.
+ *
+ * Admin pages read across every account, so the message is shown verbatim: the
+ * audience is staff, and the detail is what makes the failure diagnosable.
+ */
+export default function AdminError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ useEffect(() => {
+ console.error("[admin]", error)
+ }, [error])
+
+ return (
+
+ )
+}
diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx
index 992ac8d..5a1486b 100644
--- a/app/(auth)/signup/page.tsx
+++ b/app/(auth)/signup/page.tsx
@@ -3,6 +3,18 @@ import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { signUp, signInWithGoogle } from "@/app/actions/auth"
import { isGoogleConfigured } from "@/lib/auth"
+import { pageMetadata } from "@/lib/seo"
+
+// /signup is listed in the sitemap as a conversion landing page, so it needs
+// its own title, description and canonical rather than inheriting the "Sign in"
+// title from app/(auth)/layout.tsx.
+export const metadata = pageMetadata({
+ title: "Create your free Property Management Network account",
+ absoluteTitle: true,
+ description:
+ "Create a free landlord account — track rent, maintenance, leases and expenses for your first property. No credit card required.",
+ path: "/signup",
+})
export default async function SignupPage({
searchParams,
diff --git a/app/(marketing)/acceptable-use/page.tsx b/app/(marketing)/acceptable-use/page.tsx
index 3da5989..0c4f494 100644
--- a/app/(marketing)/acceptable-use/page.tsx
+++ b/app/(marketing)/acceptable-use/page.tsx
@@ -1,11 +1,12 @@
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal"
+import { pageMetadata } from "@/lib/seo"
-export const metadata = {
+export const metadata = pageMetadata({
title: "Acceptable Use Policy",
description: `The rules that govern acceptable use of the ${LEGAL.service} platform and the activities that are prohibited.`,
- alternates: { canonical: "/acceptable-use" },
-}
+ path: "/acceptable-use",
+})
export default function Page() {
return (
diff --git a/app/(marketing)/api-docs/page.tsx b/app/(marketing)/api-docs/page.tsx
index 5b3c2fb..62c9183 100644
--- a/app/(marketing)/api-docs/page.tsx
+++ b/app/(marketing)/api-docs/page.tsx
@@ -1,12 +1,14 @@
import Link from "next/link"
import { Code2, Lock, Zap, BookOpen, Webhook } from "lucide-react"
import { WEBHOOK_EVENTS } from "@/lib/webhooks/events"
+import { pageMetadata } from "@/lib/seo"
-export const metadata = {
+export const metadata = pageMetadata({
title: "API Docs",
- description: "Property Management Network REST API documentation for developers.",
- alternates: { canonical: "/api-docs" },
-}
+ description:
+ "REST API reference for Property Management Network — endpoints for properties, tenants, rent payments, maintenance and webhooks, with API key auth.",
+ path: "/api-docs",
+})
// The real, deployed origin. Falls back to a placeholder only when the env var
// isn't set (e.g. local docs previews).
diff --git a/app/(marketing)/cookie-policy/page.tsx b/app/(marketing)/cookie-policy/page.tsx
index bbda96b..94bb56b 100644
--- a/app/(marketing)/cookie-policy/page.tsx
+++ b/app/(marketing)/cookie-policy/page.tsx
@@ -1,12 +1,12 @@
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal"
+import { pageMetadata } from "@/lib/seo"
-export const metadata = {
+export const metadata = pageMetadata({
title: "Cookie Policy",
- description:
- "How we use cookies and similar technologies, what we deliberately avoid, and how to manage them in your browser.",
- alternates: { canonical: "/cookie-policy" },
-}
+ description: "How we use cookies and similar technologies, what we deliberately avoid, and how to manage them in your browser.",
+ path: "/cookie-policy",
+})
const COOKIE_ROWS: { name: string; type: string; purpose: string; expires: string }[] = [
{
diff --git a/app/(marketing)/disclaimer/page.tsx b/app/(marketing)/disclaimer/page.tsx
index 5dc606f..c903069 100644
--- a/app/(marketing)/disclaimer/page.tsx
+++ b/app/(marketing)/disclaimer/page.tsx
@@ -1,12 +1,12 @@
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal"
+import { pageMetadata } from "@/lib/seo"
-export const metadata = {
+export const metadata = pageMetadata({
title: "Disclaimer",
- description:
- "Important limitations on the information and outputs provided by the Service, including AI-generated content and financial reports.",
- alternates: { canonical: "/disclaimer" },
-}
+ description: "Important limitations on the information and outputs provided by the Service, including AI-generated content and financial reports.",
+ path: "/disclaimer",
+})
export default function Page() {
return (
diff --git a/app/(marketing)/dpa/page.tsx b/app/(marketing)/dpa/page.tsx
index 79dec27..d43ec24 100644
--- a/app/(marketing)/dpa/page.tsx
+++ b/app/(marketing)/dpa/page.tsx
@@ -1,12 +1,13 @@
import Link from "next/link"
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal"
+import { pageMetadata } from "@/lib/seo"
-export const metadata = {
+export const metadata = pageMetadata({
title: "Data Processing Addendum",
description: `The Data Processing Addendum governing how ${LEGAL.entity} processes personal data on behalf of Customers using ${LEGAL.service}.`,
- alternates: { canonical: "/dpa" },
-}
+ path: "/dpa",
+})
export default function DpaPage() {
return (
diff --git a/app/(marketing)/gdpr/page.tsx b/app/(marketing)/gdpr/page.tsx
index cc74991..9d0515e 100644
--- a/app/(marketing)/gdpr/page.tsx
+++ b/app/(marketing)/gdpr/page.tsx
@@ -1,12 +1,13 @@
import Link from "next/link"
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal"
+import { pageMetadata } from "@/lib/seo"
-export const metadata = {
+export const metadata = pageMetadata({
title: "GDPR & Data Rights",
description: `How ${LEGAL.entity} complies with the GDPR and UK GDPR, and the data rights available to you as a data subject.`,
- alternates: { canonical: "/gdpr" },
-}
+ path: "/gdpr",
+})
export default function GdprPage() {
return (
diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx
index e9ae79b..ac04fa7 100644
--- a/app/(marketing)/layout.tsx
+++ b/app/(marketing)/layout.tsx
@@ -1,6 +1,6 @@
import { Navbar } from "@/components/marketing/navbar"
import { Footer } from "@/components/marketing/footer"
-import { StructuredData } from "@/components/marketing/structured-data"
+import { SiteStructuredData } from "@/components/marketing/structured-data"
import { getSession, isAdminUser } from "@/lib/session"
import { getMaintenanceMode } from "@/lib/settings"
import { MaintenanceScreen } from "@/components/shared/maintenance-screen"
@@ -17,7 +17,7 @@ export default async function MarketingLayout({ children }: { children: React.Re
return (
-
+
{children}
diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx
index 87b58cd..0e38974 100644
--- a/app/(marketing)/page.tsx
+++ b/app/(marketing)/page.tsx
@@ -7,23 +7,23 @@ import { Testimonials } from "@/components/marketing/testimonials"
import { PricingSection } from "@/components/marketing/pricing-section"
import { FAQ } from "@/components/marketing/faq"
import { CtaBanner } from "@/components/marketing/cta-banner"
+import { HomeStructuredData } from "@/components/marketing/structured-data"
import { annualEnabled } from "@/lib/stripe/plans"
+import { pageMetadata } from "@/lib/seo"
-export const metadata = {
- title: { absolute: "Property Management Software for Independent Landlords" },
- description: "Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.",
- alternates: { canonical: "/" },
- openGraph: {
- title: "Property management without the chaos",
- description: "Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.",
- url: "/",
- type: "website",
- },
-}
+export const metadata = pageMetadata({
+ title: "Property Management Software for Independent Landlords",
+ absoluteTitle: true,
+ description:
+ "Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.",
+ path: "/",
+ socialTitle: "Property management without the chaos",
+})
export default function LandingPage() {
return (
<>
+
diff --git a/app/(marketing)/privacy/page.tsx b/app/(marketing)/privacy/page.tsx
index 1d6c8ab..6fe731f 100644
--- a/app/(marketing)/privacy/page.tsx
+++ b/app/(marketing)/privacy/page.tsx
@@ -1,12 +1,12 @@
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL, SUBPROCESSORS } from "@/lib/legal"
+import { pageMetadata } from "@/lib/seo"
-export const metadata = {
+export const metadata = pageMetadata({
title: "Privacy Policy",
- description:
- "How we collect, use, share, and protect personal data, and the privacy rights available to you under the GDPR and California law.",
- alternates: { canonical: "/privacy" },
-}
+ description: "How we collect, use, share, and protect personal data, and the privacy rights available to you under the GDPR and California law.",
+ path: "/privacy",
+})
export default function Page() {
return (
diff --git a/app/(marketing)/refund-policy/page.tsx b/app/(marketing)/refund-policy/page.tsx
index c0f1fbf..6cfcf83 100644
--- a/app/(marketing)/refund-policy/page.tsx
+++ b/app/(marketing)/refund-policy/page.tsx
@@ -1,11 +1,12 @@
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal"
+import { pageMetadata } from "@/lib/seo"
-export const metadata = {
- title: "Refund & Cancellation Policy",
+export const metadata = pageMetadata({
+ title: "Refund & Cancellation",
description: `How subscriptions, cancellations, and refunds work for the ${LEGAL.service} platform.`,
- alternates: { canonical: "/refund-policy" },
-}
+ path: "/refund-policy",
+})
export default function Page() {
return (
diff --git a/app/(marketing)/subprocessors/page.tsx b/app/(marketing)/subprocessors/page.tsx
index 0e1d592..09c4ec7 100644
--- a/app/(marketing)/subprocessors/page.tsx
+++ b/app/(marketing)/subprocessors/page.tsx
@@ -1,12 +1,13 @@
import Link from "next/link"
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL, SUBPROCESSORS } from "@/lib/legal"
+import { pageMetadata } from "@/lib/seo"
-export const metadata = {
+export const metadata = pageMetadata({
title: "Sub-processors",
description: `The third-party sub-processors that ${LEGAL.entity} engages to process personal data on behalf of Customers of ${LEGAL.service}.`,
- alternates: { canonical: "/subprocessors" },
-}
+ path: "/subprocessors",
+})
export default function SubprocessorsPage() {
return (
diff --git a/app/(marketing)/tenant-portal-info/page.tsx b/app/(marketing)/tenant-portal-info/page.tsx
index 46fa9f9..5c30a92 100644
--- a/app/(marketing)/tenant-portal-info/page.tsx
+++ b/app/(marketing)/tenant-portal-info/page.tsx
@@ -1,11 +1,13 @@
import Link from "next/link"
import { Building2, CheckCircle2, Smartphone, Bell, FileText, ArrowRight } from "lucide-react"
+import { pageMetadata } from "@/lib/seo"
-export const metadata = {
+export const metadata = pageMetadata({
title: "Tenant Portal",
- description: "Give your tenants a dedicated portal to pay rent, submit maintenance requests, and view lease details.",
- alternates: { canonical: "/tenant-portal-info" },
-}
+ description:
+ "Give your tenants a dedicated portal to pay rent, submit maintenance requests, and view lease details.",
+ path: "/tenant-portal-info",
+})
const FEATURES = [
{ icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20", title: "Online Rent Payment", desc: "Tenants pay rent securely via Stripe — card or bank transfer. Auto-receipts sent by email." },
diff --git a/app/(marketing)/terms/page.tsx b/app/(marketing)/terms/page.tsx
index 0838b88..3812e84 100644
--- a/app/(marketing)/terms/page.tsx
+++ b/app/(marketing)/terms/page.tsx
@@ -1,11 +1,12 @@
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal"
+import { pageMetadata } from "@/lib/seo"
-export const metadata = {
+export const metadata = pageMetadata({
title: "Terms of Service",
description: `The terms and conditions that govern your access to and use of the ${LEGAL.service} platform.`,
- alternates: { canonical: "/terms" },
-}
+ path: "/terms",
+})
export default function Page() {
return (
diff --git a/app/actions/admin.ts b/app/actions/admin.ts
index 93076c1..06b144c 100644
--- a/app/actions/admin.ts
+++ b/app/actions/admin.ts
@@ -13,6 +13,13 @@ import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
import { profiles, user as userTable } from "@/lib/db/schema"
import { executeAccountDeletion } from "@/lib/gdpr/delete"
+import {
+ changePlanInStripe,
+ cancelSubscription,
+ resumeSubscription,
+ refundCharge,
+} from "@/lib/admin/billing"
+import type { Plan } from "@/types"
// ── gate ────────────────────────────────────────────────────────────────────
// Every server action re-verifies the caller is an admin. NEVER skip — these
@@ -26,24 +33,152 @@ async function guard() {
const planSchema = z.enum(["starter", "pro", "landlord", "lifetime"])
// ── change plan ─────────────────────────────────────────────────────────────
-export async function changeUserPlan(userId: string, plan: string) {
+// TWO DISTINCT OPERATIONS, deliberately not merged:
+//
+// "comp" — grant the entitlement in our database only. Stripe is untouched,
+// so the user is billed exactly as before. This is the right choice
+// for a free upgrade, a support gesture, or a user with no
+// subscription at all.
+// "stripe" — actually move their Stripe subscription (prorated), then mirror
+// it locally. This CHARGES OR CREDITS REAL MONEY.
+//
+// Before this split, the only behaviour was "comp" while the UI called it
+// "change plan" — so an admin granting Pro left the customer on their old
+// Stripe subscription, silently desyncing entitlement from billing.
+export async function changeUserPlan(
+ userId: string,
+ plan: string,
+ mode: "comp" | "stripe" = "comp",
+ interval: "month" | "year" = "month"
+) {
const a = await guard()
const nextPlan = planSchema.parse(plan)
const existing = await db.query.profiles.findFirst({ where: eq(profiles.id, userId) })
const oldPlan = existing?.plan ?? null
+ if (mode === "stripe") {
+ const result = await changePlanInStripe(userId, nextPlan as Plan, interval)
+ if (!result.ok) return { ok: false as const, error: result.error }
+
+ await logAdminAction({
+ adminId: a.user.id,
+ action: "plan_change_stripe",
+ targetUserId: userId,
+ metadata: { from: oldPlan, to: nextPlan, interval, detail: result.detail },
+ })
+
+ revalidatePath(`/admin/users/${userId}`)
+ return { ok: true as const, detail: result.detail }
+ }
+
+ // Comp: entitlement only. Recorded as such so the audit trail distinguishes a
+ // deliberate free grant from a paid upgrade.
await db.update(profiles).set({ plan: nextPlan }).where(eq(profiles.id, userId))
await logAdminAction({
adminId: a.user.id,
action: "plan_change",
targetUserId: userId,
- metadata: { from: oldPlan, to: nextPlan },
+ metadata: { from: oldPlan, to: nextPlan, mode: "comp", billingUnchanged: true },
})
revalidatePath(`/admin/users/${userId}`)
- return { ok: true }
+ return {
+ ok: true as const,
+ detail: `Plan set to ${nextPlan} as a comp. Stripe billing was NOT changed.`,
+ }
+}
+
+// ── subscription lifecycle ──────────────────────────────────────────────────
+export async function cancelUserSubscription(userId: string, immediate = false) {
+ const a = await guard()
+ const result = await cancelSubscription(userId, immediate)
+ if (!result.ok) return { ok: false as const, error: result.error }
+
+ await logAdminAction({
+ adminId: a.user.id,
+ action: "cancel_subscription",
+ targetUserId: userId,
+ metadata: { immediate, detail: result.detail },
+ })
+
+ revalidatePath(`/admin/users/${userId}`)
+ return { ok: true as const, detail: result.detail }
+}
+
+export async function resumeUserSubscription(userId: string) {
+ const a = await guard()
+ const result = await resumeSubscription(userId)
+ if (!result.ok) return { ok: false as const, error: result.error }
+
+ await logAdminAction({
+ adminId: a.user.id,
+ action: "resume_subscription",
+ targetUserId: userId,
+ metadata: { detail: result.detail },
+ })
+
+ revalidatePath(`/admin/users/${userId}`)
+ return { ok: true as const, detail: result.detail }
+}
+
+// ── refunds ─────────────────────────────────────────────────────────────────
+// `amountCents` omitted refunds everything still outstanding on the charge. The
+// charge is verified to belong to this user inside refundCharge().
+export async function refundUserCharge(
+ userId: string,
+ chargeId: string,
+ amountCents?: number,
+ reason?: "duplicate" | "fraudulent" | "requested_by_customer"
+) {
+ const a = await guard()
+ const result = await refundCharge(userId, chargeId, amountCents, reason)
+ if (!result.ok) return { ok: false as const, error: result.error }
+
+ await logAdminAction({
+ adminId: a.user.id,
+ action: "refund",
+ targetUserId: userId,
+ metadata: {
+ chargeId,
+ refundId: result.data.refundId,
+ amountCents: result.data.amount,
+ reason: reason ?? null,
+ },
+ })
+
+ revalidatePath(`/admin/users/${userId}`)
+ return { ok: true as const, detail: result.detail }
+}
+
+// ── admin role management ───────────────────────────────────────────────────
+// Promotes/demotes via the Better Auth admin plugin, which writes `user.role`.
+// This replaces the previous situation where the ONLY way to create an admin was
+// editing ADMIN_USER_IDS in env and redeploying.
+//
+// Self-demotion is blocked: an admin removing their own last access would need a
+// redeploy to undo, and ADMIN_USER_IDS remains the break-glass path.
+export async function setUserRole(userId: string, role: "admin" | "user") {
+ const a = await guard()
+ if (userId === a.user.id) {
+ throw new Error("You cannot change your own role. Ask another admin.")
+ }
+
+ await auth.api.setRole({
+ body: { userId, role },
+ headers: await headers(),
+ })
+
+ await logAdminAction({
+ adminId: a.user.id,
+ action: "set_role",
+ targetUserId: userId,
+ metadata: { role },
+ })
+
+ revalidatePath(`/admin/users/${userId}`)
+ return { ok: true as const, detail: role === "admin" ? "User promoted to admin." : "Admin access revoked." }
}
// ── ban ─────────────────────────────────────────────────────────────────────
@@ -132,7 +267,7 @@ export async function markEmailVerified(userId: string) {
await logAdminAction({
adminId: a.user.id,
- action: "resend_verification",
+ action: "mark_email_verified",
targetUserId: userId,
metadata: { markedVerified: true },
})
diff --git a/app/api/ai/predictions/route.ts b/app/api/ai/predictions/route.ts
index 332aaa5..a77802c 100644
--- a/app/api/ai/predictions/route.ts
+++ b/app/api/ai/predictions/route.ts
@@ -35,6 +35,19 @@ export async function GET() {
return NextResponse.json(data)
}
+// Shape of one item in the model's JSON response. Every field is optional
+// because the model is not a trusted schema — the insert below supplies a
+// fallback for each, so a missing key degrades instead of throwing.
+type AiPrediction = {
+ type?: string
+ title?: string
+ prediction?: string
+ confidence?: string
+ timeframe?: string
+ risk_level?: string
+ data?: Record | null
+}
+
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
@@ -184,7 +197,7 @@ Only return valid JSON, no other text.`
json: true,
})
- let predictions: any[] = []
+ let predictions: AiPrediction[] = []
try {
const parsed = JSON.parse(content || "{}")
predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? [])
@@ -195,11 +208,11 @@ Only return valid JSON, no other text.`
// Replace old predictions
await db.delete(ai_predictions).where(eq(ai_predictions.user_id, ownerId))
- const toInsert = predictions.map((p: any) => ({
+ const toInsert = predictions.map((p: AiPrediction) => ({
user_id: ownerId,
type: p.type ?? "growth_opportunity",
- title: p.title,
- prediction: p.prediction,
+ title: p.title ?? "Untitled prediction",
+ prediction: p.prediction ?? "",
confidence: p.confidence ?? "medium",
timeframe: p.timeframe ?? "Next 30 days",
risk_level: p.risk_level ?? "low",
diff --git a/app/api/ai/recommendations/route.ts b/app/api/ai/recommendations/route.ts
index ddb8e50..2f24f74 100644
--- a/app/api/ai/recommendations/route.ts
+++ b/app/api/ai/recommendations/route.ts
@@ -34,6 +34,18 @@ export async function GET() {
return NextResponse.json(data)
}
+// Shape of one item in the model's JSON response. Optional for the same reason
+// as AiPrediction: the model output is untrusted input, not a schema.
+type AiRecommendation = {
+ type?: string
+ title?: string
+ description?: string
+ impact?: string
+ priority?: string
+ action_label?: string
+ action_data?: Record | null
+}
+
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
@@ -169,7 +181,7 @@ Return a JSON object with key "recommendations" containing an array. Each recomm
Only return valid JSON, no other text.`
- let recommendations: any[] = []
+ let recommendations: AiRecommendation[] = []
try {
const content = await aiComplete({
messages: [{ role: "user", content: prompt }],
@@ -178,8 +190,9 @@ Only return valid JSON, no other text.`
})
const parsed = JSON.parse(content || "{}")
recommendations = Array.isArray(parsed) ? parsed : (parsed.recommendations ?? [])
- } catch (err: any) {
- return NextResponse.json({ error: err?.message ?? "AI generation failed" }, { status: 500 })
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "AI generation failed"
+ return NextResponse.json({ error: message }, { status: 500 })
}
// Delete old pending recommendations and insert new ones
@@ -187,12 +200,12 @@ Only return valid JSON, no other text.`
.delete(ai_recommendations)
.where(and(eq(ai_recommendations.user_id, ownerId), eq(ai_recommendations.status, "pending")))
- const toInsert = recommendations.map((r: any) => ({
+ const toInsert = recommendations.map((r: AiRecommendation) => ({
user_id: ownerId,
type: r.type ?? "opportunity",
- title: r.title,
- description: r.description,
- impact: r.impact,
+ title: r.title ?? "Untitled recommendation",
+ description: r.description ?? "",
+ impact: r.impact ?? "",
priority: r.priority ?? "medium",
status: "pending",
action_label: r.action_label ?? "Apply",
diff --git a/app/api/ai/rent-receipt/route.ts b/app/api/ai/rent-receipt/route.ts
index 4054a0d..8781803 100644
--- a/app/api/ai/rent-receipt/route.ts
+++ b/app/api/ai/rent-receipt/route.ts
@@ -38,7 +38,9 @@ export async function POST(request: Request) {
}
// Pass only the whitelisted, validated fields to the model.
- const { payment_id, ...receiptFields } = parsed.data
+ // payment_id identifies the row but must not reach the model — destructured
+ // out deliberately, hence the leading underscore.
+ const { payment_id: _payment_id, ...receiptFields } = parsed.data
const text = await aiComplete({
messages: [
diff --git a/app/api/calendar/[token]/route.ts b/app/api/calendar/[token]/route.ts
index 90e2fc6..cd9b842 100644
--- a/app/api/calendar/[token]/route.ts
+++ b/app/api/calendar/[token]/route.ts
@@ -115,7 +115,12 @@ export async function GET(_req: Request, { params }: { params: Promise<{ token:
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="property-management-network.ics"',
- "Cache-Control": "public, max-age=3600",
+ // PRIVATE, never shared-cacheable: the only credential is the token in the
+ // URL, and the body carries tenant names, rent amounts, property addresses
+ // and lease dates. A `public` cache directive would let any intermediary
+ // or CDN retain that PII.
+ "Cache-Control": "private, max-age=3600",
+ "X-Robots-Tag": "noindex, nofollow",
},
})
}
diff --git a/app/api/maintenance/route.ts b/app/api/maintenance/route.ts
index ce7ee96..9111927 100644
--- a/app/api/maintenance/route.ts
+++ b/app/api/maintenance/route.ts
@@ -8,6 +8,7 @@ import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { logActivity } from "@/lib/activity"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
+import { enforceRateLimit, clientIp } from "@/lib/rate-limit"
const VALID_STATUSES = ["open", "in_progress", "resolved", "closed"]
const VALID_PRIORITIES = ["low", "medium", "high", "emergency"]
@@ -62,12 +63,22 @@ export async function POST(request: Request) {
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
userId = ctx.ownerId
} else {
- // Tenant portal submission — verify portal_token
+ // Tenant portal submission — verify portal_token.
+ //
+ // This is the one unauthenticated write path in the app, so it carries its
+ // own limits: a per-IP budget that also caps portal-token guessing, and a
+ // tighter per-token budget so a leaked token cannot flood a landlord's queue.
+ const ipLimited = enforceRateLimit(`portal-maintenance-ip:${clientIp(request)}`, 20, 3600)
+ if (ipLimited) return ipLimited
+
const portalToken = body.portal_token as string | undefined
if (!portalToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
+ const tokenLimited = enforceRateLimit(`portal-maintenance:${portalToken}`, 10, 3600)
+ if (tokenLimited) return tokenLimited
+
const tenant = await db.query.tenants.findFirst({
where: eq(tenants.portal_token, portalToken),
columns: { id: true, user_id: true, property_id: true, unit_id: true },
diff --git a/app/api/properties/route.ts b/app/api/properties/route.ts
index e6ba57f..4467455 100644
--- a/app/api/properties/route.ts
+++ b/app/api/properties/route.ts
@@ -1,11 +1,10 @@
import { NextResponse } from "next/server"
-import { desc, eq, sql } from "drizzle-orm"
+import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { propertySchema } from "@/lib/validations"
-import { getUserPlan } from "@/lib/plan-limits"
-import { checkLimit } from "@/lib/stripe/plans"
+import { checkPropertyLimit } from "@/lib/plan-limits"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { geocodeAddress } from "@/lib/geocoding"
@@ -37,16 +36,8 @@ export async function POST(request: Request) {
const parsed = propertySchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
- // Check plan limit
- const [{ count }] = await db
- .select({ count: sql`count(*)::int` })
- .from(properties)
- .where(eq(properties.user_id, ownerId))
-
- const plan = await getUserPlan(ownerId)
- if (!checkLimit(plan, "maxProperties", count)) {
- return NextResponse.json({ error: "Plan limit reached. Upgrade to add more properties." }, { status: 403 })
- }
+ const limitError = await checkPropertyLimit(ownerId)
+ if (limitError) return NextResponse.json({ error: limitError }, { status: 403 })
// Best-effort geocode so the property shows up on the map (never blocks save).
const coords = await geocodeAddress(parsed.data)
diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts
index 6d6299b..8c17522 100644
--- a/app/api/stripe/webhook/route.ts
+++ b/app/api/stripe/webhook/route.ts
@@ -5,6 +5,16 @@ import { db } from "@/lib/db"
import { profiles, rent_payments } from "@/lib/db/schema"
import type Stripe from "stripe"
+/**
+ * `current_period_end` is present on the webhook payload but absent from the
+ * Subscription type in this pinned API version, so it is read through a narrow
+ * accessor rather than casting the whole object to `any`.
+ */
+function subscriptionPeriodEnd(sub: Stripe.Subscription): number | null {
+ const v = (sub as unknown as { current_period_end?: unknown }).current_period_end
+ return typeof v === "number" ? v : null
+}
+
export async function POST(request: Request) {
const body = await request.text()
const sig = request.headers.get("stripe-signature")!
@@ -54,8 +64,8 @@ export async function POST(request: Request) {
plan: (plan ?? "starter") as typeof profiles.$inferSelect.plan,
stripe_subscription_id: subscription.id,
subscription_status: subscription.status,
- plan_expires_at: (subscription as any).current_period_end
- ? new Date((subscription as any).current_period_end * 1000).toISOString()
+ plan_expires_at: subscriptionPeriodEnd(subscription)
+ ? new Date(subscriptionPeriodEnd(subscription)! * 1000).toISOString()
: null,
})
.where(eq(profiles.id, userId))
diff --git a/app/api/tenants/route.ts b/app/api/tenants/route.ts
index d38a81d..bff6163 100644
--- a/app/api/tenants/route.ts
+++ b/app/api/tenants/route.ts
@@ -5,8 +5,7 @@ import { tenants, units } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { tenantSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit } from "@/lib/db/ownership"
-import { getUserPlan } from "@/lib/plan-limits"
-import { checkLimit } from "@/lib/stripe/plans"
+import { checkTenantLimit } from "@/lib/plan-limits"
import { logActivity } from "@/lib/activity"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
@@ -64,18 +63,8 @@ export async function POST(request: Request) {
const parsed = tenantSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
- // Enforce per-plan tenant limit (Starter = 3).
- const plan = await getUserPlan(ownerId)
- const [{ count }] = await db
- .select({ count: sql`count(*)::int` })
- .from(tenants)
- .where(eq(tenants.user_id, ownerId))
- if (!checkLimit(plan, "maxTenants", count)) {
- return NextResponse.json(
- { error: "Plan limit reached. Upgrade to add more tenants." },
- { status: 403 }
- )
- }
+ const limitError = await checkTenantLimit(ownerId)
+ if (limitError) return NextResponse.json({ error: limitError }, { status: 403 })
if (
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts
index 8e5be3a..e555229 100644
--- a/app/api/upload/route.ts
+++ b/app/api/upload/route.ts
@@ -9,6 +9,7 @@ import {
extOf,
} from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits"
+import { enforceRateLimit } from "@/lib/rate-limit"
const ALLOWED_SCOPES = ["property-images", "maintenance", "documents", "misc"]
@@ -24,6 +25,11 @@ export async function POST(request: Request) {
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const ownerId = ctx.ownerId
+ // Storage quota caps total bytes, but not the rate of writes — throttle so a
+ // single account cannot hammer Spaces (or fill a plan's quota) in one burst.
+ const limited = enforceRateLimit(`upload:${ownerId}`, 60, 60)
+ if (limited) return limited
+
const fd = await request.formData()
const file = fd.get("file") as File | null
const scopeRaw = (fd.get("scope") as string) || "misc"
diff --git a/app/api/v1/maintenance/[id]/route.ts b/app/api/v1/maintenance/[id]/route.ts
index d22da38..828eb50 100644
--- a/app/api/v1/maintenance/[id]/route.ts
+++ b/app/api/v1/maintenance/[id]/route.ts
@@ -6,6 +6,7 @@ import { maintenance_requests } from "@/lib/db/schema"
import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth"
+import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — update a single maintenance request. Bearer API-key
@@ -26,6 +27,10 @@ const maintenancePatchSchema = maintenanceSchema.partial().extend({
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
if (!ctx.canWrite) return forbidden()
const { id } = await params
diff --git a/app/api/v1/maintenance/route.ts b/app/api/v1/maintenance/route.ts
index d0b2c99..efb3d44 100644
--- a/app/api/v1/maintenance/route.ts
+++ b/app/api/v1/maintenance/route.ts
@@ -5,6 +5,7 @@ import { maintenance_requests } from "@/lib/db/schema"
import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth"
+import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — maintenance requests. Bearer API-key auth.
@@ -22,6 +23,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
+
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const priority = searchParams.get("priority")
@@ -55,6 +60,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
diff --git a/app/api/v1/payments/route.ts b/app/api/v1/payments/route.ts
index 02ab714..5c38191 100644
--- a/app/api/v1/payments/route.ts
+++ b/app/api/v1/payments/route.ts
@@ -5,6 +5,7 @@ import { rent_payments } from "@/lib/db/schema"
import { rentPaymentSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth"
+import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — rent payments. Bearer API-key auth. Maps to the
@@ -21,6 +22,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
+
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const tenantId = searchParams.get("tenant_id")
@@ -57,6 +62,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
diff --git a/app/api/v1/properties/route.ts b/app/api/v1/properties/route.ts
index 976919f..333b144 100644
--- a/app/api/v1/properties/route.ts
+++ b/app/api/v1/properties/route.ts
@@ -4,6 +4,8 @@ import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema"
import { propertySchema } from "@/lib/validations"
import { resolveApiRequest } from "@/lib/api-auth"
+import { enforceRateLimit } from "@/lib/rate-limit"
+import { checkPropertyLimit } from "@/lib/plan-limits"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { geocodeAddress } from "@/lib/geocoding"
@@ -19,6 +21,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
+
const data = await db.query.properties.findMany({
where: eq(properties.user_id, ctx.ownerId),
with: { units: { columns: { id: true, status: true } } },
@@ -31,6 +37,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
@@ -42,6 +52,14 @@ export async function POST(request: Request) {
)
}
+ // Same plan cap the session route enforces — the public API is a creation
+ // entry point too, and skipping this here let a Starter key create unlimited
+ // properties.
+ const limitError = await checkPropertyLimit(ctx.ownerId)
+ if (limitError) {
+ return NextResponse.json({ error: { code: 403, message: limitError } }, { status: 403 })
+ }
+
const coords = await geocodeAddress(parsed.data)
const [data] = await db
diff --git a/app/api/v1/tenants/route.ts b/app/api/v1/tenants/route.ts
index 5dfd195..03e5620 100644
--- a/app/api/v1/tenants/route.ts
+++ b/app/api/v1/tenants/route.ts
@@ -3,6 +3,7 @@ import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { tenants } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth"
+import { enforceRateLimit } from "@/lib/rate-limit"
// Public REST API (v1) — Bearer API-key auth. Scoped by the resolved owner id.
@@ -13,6 +14,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
+
const { searchParams } = new URL(request.url)
const propertyId = searchParams.get("property_id")
const status = searchParams.get("status")
diff --git a/app/api/v1/webhooks/[id]/route.ts b/app/api/v1/webhooks/[id]/route.ts
index cf860a9..6bf408f 100644
--- a/app/api/v1/webhooks/[id]/route.ts
+++ b/app/api/v1/webhooks/[id]/route.ts
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth"
+import { enforceRateLimit } from "@/lib/rate-limit"
import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
@@ -32,6 +33,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
+
const { id } = await params
const data = await db.query.webhook_endpoints.findFirst({
where: and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ctx.ownerId)),
@@ -44,6 +49,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
if (!ctx.canWrite) return forbidden()
const { id } = await params
@@ -93,6 +102,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
if (!ctx.canWrite) return forbidden()
const { id } = await params
diff --git a/app/api/v1/webhooks/route.ts b/app/api/v1/webhooks/route.ts
index ae0082a..b8cce47 100644
--- a/app/api/v1/webhooks/route.ts
+++ b/app/api/v1/webhooks/route.ts
@@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth"
+import { enforceRateLimit } from "@/lib/rate-limit"
import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
@@ -37,6 +38,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
+
const data = await db
.select(PUBLIC_COLUMNS)
.from(webhook_endpoints)
@@ -49,6 +54,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
+
+ // Public API budget: 120 requests/minute per key owner.
+ const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
+ if (limited) return limited
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
diff --git a/app/not-found.tsx b/app/not-found.tsx
index 7e47aa1..42a444b 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -1,5 +1,14 @@
+import type { Metadata } from "next"
import Link from "next/link"
+// A 404 already returns the right status code, but without its own title it
+// would surface the site-wide default title in tabs, share previews and logs.
+// Next.js emits its own `noindex` for not-found, so no robots field here —
+// adding one only produces a second, redundant .
+export const metadata: Metadata = {
+ title: "Page not found",
+}
+
export default function NotFound() {
return (
diff --git a/app/team/accept/[token]/page.tsx b/app/team/accept/[token]/page.tsx
index d06e1b7..3f16d9a 100644
--- a/app/team/accept/[token]/page.tsx
+++ b/app/team/accept/[token]/page.tsx
@@ -5,7 +5,12 @@ import { acceptInvite } from "@/app/actions/team"
import { Logo } from "@/components/shared/logo"
import { XCircle } from "lucide-react"
-export const metadata = { title: "Accept Team Invite" }
+// The URL carries a single-use invite token, so this page is noindex/nofollow
+// to keep tokens out of search results.
+export const metadata = {
+ title: "Accept team invite",
+ robots: { index: false, follow: false },
+}
export default async function AcceptInvitePage({
params,
diff --git a/components/admin/admin-charts.tsx b/components/admin/admin-charts.tsx
index d7e51bb..3b8e9b0 100644
--- a/components/admin/admin-charts.tsx
+++ b/components/admin/admin-charts.tsx
@@ -19,15 +19,21 @@ export function PlanDonut({ data }: { data: Record }) {
const radius = (size - stroke) / 2
const circumference = 2 * Math.PI * radius
- // Build cumulative arc segments
- let cumulative = 0
- const segments = PLAN_META.map((p) => {
- const value = data[p.key] ?? 0
- const fraction = total > 0 ? value / total : 0
- const dash = fraction * circumference
- const offset = cumulative * circumference
- cumulative += fraction
- return { ...p, value, fraction, dash, offset }
+ // Build cumulative arc segments. The running offset is derived per segment
+ // from the slices before it rather than mutated across the map callback —
+ // reassigning a closed-over local during render is what react-hooks
+ // /immutability flags, and it misbehaves under re-render.
+ const fractions = PLAN_META.map((p) => (total > 0 ? (data[p.key] ?? 0) / total : 0))
+ const segments = PLAN_META.map((p, i) => {
+ const fraction = fractions[i]
+ const precedingFraction = fractions.slice(0, i).reduce((sum, f) => sum + f, 0)
+ return {
+ ...p,
+ value: data[p.key] ?? 0,
+ fraction,
+ dash: fraction * circumference,
+ offset: precedingFraction * circumference,
+ }
})
return (
diff --git a/components/admin/billing-actions.tsx b/components/admin/billing-actions.tsx
new file mode 100644
index 0000000..10f3421
--- /dev/null
+++ b/components/admin/billing-actions.tsx
@@ -0,0 +1,256 @@
+"use client"
+
+import { useState, useTransition } from "react"
+import { useRouter } from "next/navigation"
+import { toast } from "sonner"
+import { Receipt, RotateCcw, ExternalLink } from "lucide-react"
+import { refundUserCharge } from "@/app/actions/admin"
+import { Select } from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+
+export type ChargeRow = {
+ id: string
+ amount: number
+ amountRefunded: number
+ currency: string
+ created: number
+ status: string
+ refunded: boolean
+ description: string | null
+ receiptUrl: string | null
+}
+
+const REASON_OPTIONS = [
+ { value: "requested_by_customer", label: "Requested by customer" },
+ { value: "duplicate", label: "Duplicate charge" },
+ { value: "fraudulent", label: "Fraudulent" },
+]
+
+function money(cents: number, currency: string) {
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: currency.toUpperCase(),
+ }).format(cents / 100)
+}
+
+/**
+ * Recent Stripe charges with a refund control per row.
+ *
+ * Partial refunds are entered in DOLLARS and converted to integer cents here;
+ * the server re-validates the amount against what is actually still refundable
+ * on the charge, so a stale page cannot over-refund.
+ */
+export function BillingActions({ userId, charges }: { userId: string; charges: ChargeRow[] }) {
+ const router = useRouter()
+ const [isPending, startTransition] = useTransition()
+ const [target, setTarget] = useState(null)
+ const [amount, setAmount] = useState("")
+ const [reason, setReason] = useState("requested_by_customer")
+
+ function openRefund(c: ChargeRow) {
+ setTarget(c)
+ // Default to the full remaining amount, which is the common case.
+ setAmount(((c.amount - c.amountRefunded) / 100).toFixed(2))
+ setReason("requested_by_customer")
+ }
+
+ function submitRefund() {
+ if (!target) return
+ const remaining = target.amount - target.amountRefunded
+ const parsed = Math.round(parseFloat(amount) * 100)
+
+ if (!Number.isFinite(parsed) || parsed <= 0) {
+ toast.error("Enter a refund amount greater than zero.")
+ return
+ }
+ if (parsed > remaining) {
+ toast.error(`Only ${money(remaining, target.currency)} is still refundable on that charge.`)
+ return
+ }
+
+ // A full refund sends no amount so Stripe refunds the exact remainder —
+ // avoids a rounding mismatch on odd amounts.
+ const amountCents = parsed === remaining ? undefined : parsed
+
+ startTransition(async () => {
+ try {
+ const res = await refundUserCharge(
+ userId,
+ target.id,
+ amountCents,
+ reason as "duplicate" | "fraudulent" | "requested_by_customer"
+ )
+ if (res.ok === false) {
+ toast.error(res.error ?? "Refund failed")
+ return
+ }
+ toast.success(res.detail ?? "Refund issued")
+ setTarget(null)
+ router.refresh()
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : "Refund failed")
+ }
+ })
+ }
+
+ if (!charges.length) {
+ return (
+
- {/* Danger zone */}
+ {/* ── Danger zone ────────────────────────────────────────────────────── */}
@@ -199,7 +369,10 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
{/* Ban modal (with reason input) */}
{showBan && (
-
!isPending && setShowBan(false)} />
+
!isPending && setShowBan(false)}
+ />
@@ -209,7 +382,9 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
The user will be signed out and blocked from signing in until unbanned.
-
+
setBanReason(e.target.value)}
@@ -226,7 +401,12 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
Cancel
diff --git a/components/marketing/faq.tsx b/components/marketing/faq.tsx
index e343393..83270ca 100644
--- a/components/marketing/faq.tsx
+++ b/components/marketing/faq.tsx
@@ -1,43 +1,9 @@
"use client"
import { useState } from "react"
-import { motion, AnimatePresence } from "framer-motion"
+import { motion } from "framer-motion"
import { Plus, Minus } from "lucide-react"
-
-const FAQS = [
- {
- q: "Is there really a free plan?",
- a: "Yes — the Starter plan is free forever. You get 1 property, up to 3 tenants, rent tracking, maintenance requests, and lease management. No credit card needed to sign up.",
- },
- {
- q: "What happens when my trial ends?",
- a: "There's no trial — paid plans start immediately when you upgrade. If you're on the free Starter plan, it never expires. You upgrade when you outgrow it.",
- },
- {
- q: "Can I cancel anytime?",
- a: "Yes. Cancel any time from your billing portal — no questions, no fees. Your data stays accessible for 30 days after cancellation so you can export everything.",
- },
- {
- q: "Do tenants need to create an account?",
- a: "No. Each tenant gets a unique private link to their portal — no account creation, no password. They can view rent history and submit maintenance requests instantly.",
- },
- {
- q: "Does Property Management Network handle actual rent collection?",
- a: "Yes — the Pro and Landlord plans include Stripe payment link generation. You can create a payment link for each tenant and they pay directly via card or bank transfer.",
- },
- {
- q: "Is my data secure?",
- a: "Yes. Every request is authenticated and scoped to your account, so landlords only ever see their own data and tenants only see their own records. All data is encrypted at rest and in transit.",
- },
- {
- q: "Can I manage multiple properties?",
- a: "The Starter plan supports 1 property. Pro supports up to 10. Landlord and Lifetime plans support unlimited properties and units.",
- },
- {
- q: "What's included in the Lifetime deal?",
- a: "The Lifetime plan is a one-time payment of $199. You get everything in the Landlord plan — unlimited properties, tenants, 25GB storage, AI calls, team access — with no recurring fees, ever. All future updates included.",
- },
-]
+import { FAQS } from "@/lib/marketing/faqs"
export function FAQ() {
const [open, setOpen] = useState(null)
@@ -56,45 +22,64 @@ export function FAQ() {
+
+
+ {/*
+ The answer stays mounted and is collapsed by animating its height
+ rather than being conditionally rendered. Google requires the
+ answer text behind an FAQ accordion to be present in the served
+ HTML — unmounting it when closed would leave the FAQPage JSON-LD
+ in components/marketing/structured-data.tsx describing content no
+ crawler can see.
+ */}
+
+
+ {faq.a}
+
+
+
+ )
+ })}
)
diff --git a/components/marketing/footer.tsx b/components/marketing/footer.tsx
index 5cdedb7..70d1890 100644
--- a/components/marketing/footer.tsx
+++ b/components/marketing/footer.tsx
@@ -5,10 +5,10 @@ import { LEGAL_PAGES } from "@/lib/legal"
const LINKS = {
Product: [
- { label: "Features", href: "#features" },
- { label: "Pricing", href: "#pricing" },
- { label: "How it works", href: "#how-it-works" },
- { label: "FAQ", href: "#faq" },
+ { label: "Features", href: "/#features" },
+ { label: "Pricing", href: "/#pricing" },
+ { label: "How it works", href: "/#how-it-works" },
+ { label: "FAQ", href: "/#faq" },
],
Platform: [
{ label: "Dashboard", href: "/login" },
diff --git a/components/marketing/navbar.tsx b/components/marketing/navbar.tsx
index 4f0198e..3d8eb21 100644
--- a/components/marketing/navbar.tsx
+++ b/components/marketing/navbar.tsx
@@ -7,10 +7,10 @@ import { Menu, X, ArrowRight } from "lucide-react"
import { Logo } from "@/components/shared/logo"
const NAV_LINKS = [
- { label: "Features", href: "#features" },
- { label: "How it works", href: "#how-it-works" },
- { label: "Pricing", href: "#pricing" },
- { label: "FAQ", href: "#faq" },
+ { label: "Features", href: "/#features" },
+ { label: "How it works", href: "/#how-it-works" },
+ { label: "Pricing", href: "/#pricing" },
+ { label: "FAQ", href: "/#faq" },
]
export function Navbar() {
diff --git a/components/marketing/structured-data.tsx b/components/marketing/structured-data.tsx
index 2f03f7f..3a0779b 100644
--- a/components/marketing/structured-data.tsx
+++ b/components/marketing/structured-data.tsx
@@ -1,59 +1,28 @@
import { PLAN_AMOUNTS, getPlanLabel } from "@/lib/stripe/plans"
+import { FAQS } from "@/lib/marketing/faqs"
import type { Plan } from "@/types"
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
-// Real plans + displayed prices from lib/stripe/plans.ts (single source of truth).
-// Annual billing is auto-provisioned at checkout and has no fixed amount here, so
-// we advertise only the monthly / one-time base prices that actually exist.
-const planOrder: Plan[] = ["starter", "pro", "landlord", "lifetime"]
-const planOffers = planOrder.map((plan) => ({
- "@type": "Offer",
- name: getPlanLabel(plan),
- price: String(PLAN_AMOUNTS[plan]),
- priceCurrency: "USD",
-}))
+function JsonLd({ data }: { data: Record }) {
+ return (
+ ", "utf8")
+const SVG = Buffer.from('