feat(billing): payment infrastructure pre-prep

db: add plan, planExpiresAt, planProvider to tenant_settings (Appwrite MCP)

- schema.ts: TenantPlan type, TenantSettings plan fields
- subscription-types.ts: Emlak plan catalog (Free / Pro 499₺/ay)
- plan-limits.ts: resource limits (properties/customers/members/presentations)
  + getPlanUsage, requirePlanCapacity, PlanLimitError helpers
- subscription-actions.ts: startCheckoutAction (Polar→Shopier→mock fallback),
  activatePlanInDb / deactivatePlanInDb for webhook handlers,
  downgradeToFreeAction, getCurrentPlanAction
- /api/payments/polar/callback: verify webhook → activatePlanInDb on order/subscription events
- /api/payments/shopier/callback: verify HMAC → activate on fulfilled+paid (tenant
  email-matching TODO pending Shopier metadata support)
- /settings/billing: CurrentPlanCard with usage progress bars + UpgradeSection
- sidebar: Plan & Faturalama nav item
- PlanLimitDialog: Emlak-specific feature list
This commit is contained in:
egecankomur
2026-05-08 15:26:18 +03:00
parent 95a7cbaf0d
commit 3cce632eb3
11 changed files with 633 additions and 4 deletions
@@ -0,0 +1,69 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { CheckCircle2, XCircle } from "lucide-react";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { getEffectivePlan, getPlanUsage } from "@/lib/appwrite/plan-limits";
import { CurrentPlanCard } from "./components/current-plan-card";
import { UpgradeSection } from "./components/upgrade-section";
export const metadata: Metadata = {
title: "KovakEmlak — Plan & Faturalama",
};
export default async function BillingPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const params = await searchParams;
const upgraded = params.upgraded === "1";
const downgraded = params.downgraded === "1";
const plan = getEffectivePlan(ctx);
const { usage } = await getPlanUsage(ctx);
const officeName = ctx.settings?.officeName ?? "Ofis";
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{officeName}</p>
<h1 className="text-2xl font-bold tracking-tight">Plan & Faturalama</h1>
<p className="text-muted-foreground text-sm">
Mevcut planınızı görüntüleyin ve yönetin.
</p>
</div>
{upgraded && (
<div className="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/10 px-4 py-3 text-sm text-green-700 dark:text-green-400">
<CheckCircle2 className="h-4 w-4 shrink-0" />
Pro plana başarıyla geçtiniz. İyi kullanımlar!
</div>
)}
{downgraded && (
<div className="flex items-center gap-2 rounded-md border border-muted bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
<XCircle className="h-4 w-4 shrink-0" />
Ücretsiz plana geçildi.
</div>
)}
<div className="grid gap-6 md:grid-cols-2">
<CurrentPlanCard
plan={plan}
expiresAt={ctx.settings?.planExpiresAt ?? null}
usage={usage}
/>
<UpgradeSection currentPlan={plan} />
</div>
</div>
);
}