feat: subscription upgrade-only flow, discount codes, proration, enterprise inquiry form, payment history invoices page, fix mobile sidebar close on navigate

This commit is contained in:
egecankomur
2026-05-14 19:09:11 +03:00
parent 37b0928da6
commit 668fb7108b
11 changed files with 666 additions and 75 deletions
@@ -0,0 +1,87 @@
"use client";
import { CheckCircle, Tag } from "@/lib/icons";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
import type { PaymentEvent } from "@/lib/appwrite/schema";
const PROVIDER_LABELS: Record<string, string> = {
paytr: "PayTR",
polar: "Polar",
shopier: "Shopier",
mock: "Test",
};
export function PaymentHistory({ events }: { events: PaymentEvent[] }) {
if (events.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Ödeme Geçmişi</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Henüz ödeme kaydı bulunmuyor.</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Ödeme Geçmişi</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{events.map((event) => {
const planEntry = PLAN_CATALOG[event.plan as keyof typeof PLAN_CATALOG];
const periodLabel = event.period === "yearly" ? "Yıllık" : "Aylık";
const date = new Date(event.$createdAt).toLocaleDateString("tr-TR", {
day: "2-digit",
month: "long",
year: "numeric",
});
return (
<div
key={event.$id}
className="flex items-center justify-between px-6 py-3.5 text-sm"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-500/10 text-green-600 shrink-0">
<CheckCircle className="h-4 w-4" />
</div>
<div>
<div className="font-medium">
{planEntry?.name ?? event.plan} {periodLabel}
</div>
<div className="text-muted-foreground text-xs flex items-center gap-1.5 mt-0.5">
<span>{date}</span>
<span>·</span>
<span>{PROVIDER_LABELS[event.provider] ?? event.provider}</span>
{event.discountCode && (
<>
<span>·</span>
<span className="flex items-center gap-0.5">
<Tag className="h-3 w-3" />
{event.discountCode}
</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-green-600 border-green-500/30 bg-green-500/5">
{event.amount.toLocaleString("tr-TR")}
</Badge>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
@@ -2,17 +2,17 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Crown, Check, CircleNotch, Star, Lightning, X } from "@/lib/icons"; import { Crown, Check, CircleNotch, Star, Lightning, X, Envelope, Tag } from "@/lib/icons";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { import {
startCheckoutAction, startCheckoutAction,
downgradeToFreeAction,
getPayTRTokenAction, getPayTRTokenAction,
requestEnterpriseInquiryAction,
} from "@/lib/appwrite/subscription-actions"; } from "@/lib/appwrite/subscription-actions";
import { PLAN_CATALOG, planPriceDisplay, planPrice } from "@/lib/appwrite/subscription-types"; import { PLAN_CATALOG, PLAN_RANK, planPriceDisplay, planPrice, validateDiscountCode } from "@/lib/appwrite/subscription-types";
import type { TenantPlan, PlanPeriod } from "@/lib/appwrite/schema"; import type { TenantPlan, PlanPeriod } from "@/lib/appwrite/schema";
const PLAN_ICONS: Record<string, React.ReactNode> = { const PLAN_ICONS: Record<string, React.ReactNode> = {
@@ -21,22 +21,79 @@ const PLAN_ICONS: Record<string, React.ReactNode> = {
enterprise: <Crown className="h-5 w-5" />, enterprise: <Crown className="h-5 w-5" />,
}; };
function calcProration(
currentPlan: TenantPlan,
currentPeriod: PlanPeriod,
planExpiresAt: string | null,
newPlanPrice: number,
): number {
if (!planExpiresAt || currentPlan === "free") return newPlanPrice;
const currentEntry = PLAN_CATALOG[currentPlan as Exclude<TenantPlan, "free">];
if (!currentEntry) return newPlanPrice;
const periodDays = currentPeriod === "yearly" ? 365 : 30;
const msRemaining = new Date(planExpiresAt).getTime() - Date.now();
const daysRemaining = Math.max(0, msRemaining / (1000 * 60 * 60 * 24));
const unusedFraction = Math.min(1, daysRemaining / periodDays);
const credit = planPrice(currentEntry, currentPeriod) * unusedFraction;
return Math.max(1, Math.round(newPlanPrice - credit));
}
export function UpgradeSection({ export function UpgradeSection({
currentPlan, currentPlan,
currentPeriod, currentPeriod,
planExpiresAt,
paytrEnabled, paytrEnabled,
}: { }: {
currentPlan: TenantPlan; currentPlan: TenantPlan;
currentPeriod?: PlanPeriod | null; currentPeriod?: PlanPeriod | null;
planExpiresAt?: string | null;
paytrEnabled?: boolean; paytrEnabled?: boolean;
}) { }) {
const [period, setPeriod] = useState<PlanPeriod>(currentPeriod ?? "monthly"); const [period, setPeriod] = useState<PlanPeriod>(currentPeriod ?? "monthly");
const [paytrToken, setPaytrToken] = useState<string | null>(null); const [paytrToken, setPaytrToken] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [enterpriseDialogOpen, setEnterpriseDialogOpen] = useState(false);
const [loadingPlan, setLoadingPlan] = useState<string | null>(null); const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
const [discountCode, setDiscountCode] = useState("");
const [discountApplied, setDiscountApplied] = useState<number | null>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [enterpriseForm, setEnterpriseForm] = useState({
teamSize: "",
listingCount: "",
needsCustomDev: "",
notes: "",
});
const plans = Object.values(PLAN_CATALOG); const plans = Object.values(PLAN_CATALOG);
const currentRank = PLAN_RANK[currentPlan];
function applyDiscount() {
if (!discountCode.trim()) return;
const fraction = validateDiscountCode(discountCode);
if (fraction === null) {
toast.error("Geçersiz indirim kodu.");
setDiscountApplied(null);
return;
}
setDiscountApplied(fraction);
toast.success(`İndirim kodu uygulandı: %${Math.round(fraction * 100)} indirim`);
}
function getDisplayPrice(plan: typeof plans[0]): number {
let base = planPriceDisplay(plan, period);
if (discountApplied !== null) base = Math.round(base * (1 - discountApplied));
return base;
}
function getProrationAmount(planId: string): number | null {
if (currentPlan === "free" || !planExpiresAt) return null;
const entry = PLAN_CATALOG[planId as Exclude<TenantPlan, "free">];
if (!entry) return null;
let base = planPrice(entry, period);
if (discountApplied !== null) base = Math.max(1, Math.round(base * (1 - discountApplied)));
const prorated = calcProration(currentPlan, currentPeriod ?? "monthly", planExpiresAt, base);
return prorated < base ? prorated : null;
}
function handleCheckout(planId: string) { function handleCheckout(planId: string) {
if (paytrEnabled) { if (paytrEnabled) {
@@ -46,6 +103,7 @@ export function UpgradeSection({
const fd = new FormData(); const fd = new FormData();
fd.set("plan", planId); fd.set("plan", planId);
fd.set("period", period); fd.set("period", period);
if (discountApplied !== null) fd.set("discountCode", discountCode.trim().toUpperCase());
const token = await getPayTRTokenAction(fd); const token = await getPayTRTokenAction(fd);
setPaytrToken(token); setPaytrToken(token);
setDialogOpen(true); setDialogOpen(true);
@@ -72,13 +130,30 @@ export function UpgradeSection({
} }
} }
async function handleDowngrade() { function handleEnterpriseInquiry() {
try { if (!enterpriseForm.teamSize || !enterpriseForm.listingCount || !enterpriseForm.needsCustomDev) {
await downgradeToFreeAction(); toast.error("Lütfen tüm zorunlu alanları doldurun.");
} catch (e) { return;
if (e instanceof Error && e.message.includes("NEXT_REDIRECT")) throw e;
toast.error("İşlem başarısız.");
} }
startTransition(async () => {
try {
const fd = new FormData();
fd.set("teamSize", enterpriseForm.teamSize);
fd.set("listingCount", enterpriseForm.listingCount);
fd.set("needsCustomDev", enterpriseForm.needsCustomDev);
fd.set("notes", enterpriseForm.notes);
const result = await requestEnterpriseInquiryAction(fd);
if (result.ok) {
setEnterpriseDialogOpen(false);
setEnterpriseForm({ teamSize: "", listingCount: "", needsCustomDev: "", notes: "" });
toast.success("Talebiniz alındı. Ekibimiz kısa sürede sizinle iletişime geçecek.");
} else {
toast.error(result.error ?? "Talep gönderilemedi.");
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "Talep gönderilemedi.");
}
});
} }
return ( return (
@@ -89,7 +164,7 @@ export function UpgradeSection({
<div> <div>
<h2 className="text-xl font-bold tracking-tight">Planınızı seçin</h2> <h2 className="text-xl font-bold tracking-tight">Planınızı seçin</h2>
<p className="text-muted-foreground text-sm mt-1"> <p className="text-muted-foreground text-sm mt-1">
İhtiyacınıza göre istediğiniz zaman değiştirebilirsiniz. Dilediğiniz zaman daha üst bir plana geçebilirsiniz.
</p> </p>
</div> </div>
<div className="flex items-center gap-1 rounded-full border bg-muted/40 p-1 text-sm"> <div className="flex items-center gap-1 rounded-full border bg-muted/40 p-1 text-sm">
@@ -117,13 +192,48 @@ export function UpgradeSection({
</Badge> </Badge>
</button> </button>
</div> </div>
{/* İndirim kodu */}
<div className="flex items-center gap-2">
<div className="relative">
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
value={discountCode}
onChange={(e) => {
setDiscountCode(e.target.value.toUpperCase());
if (discountApplied !== null) setDiscountApplied(null);
}}
onKeyDown={(e) => e.key === "Enter" && applyDiscount()}
placeholder="İndirim kodu"
className="h-9 w-40 rounded-lg border bg-background pl-8 pr-3 text-sm outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={applyDiscount}
disabled={!discountCode.trim()}
>
Uygula
</Button>
{discountApplied !== null && (
<Badge variant="secondary" className="gap-1">
<Check className="h-3 w-3" />
%{Math.round(discountApplied * 100)} indirim
</Badge>
)}
</div>
</div> </div>
{/* 3 plan kartı */} {/* 3 plan kartı */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
{plans.map((plan) => { {plans.map((plan) => {
const isCurrent = currentPlan === plan.id; const isCurrent = currentPlan === plan.id;
const displayPrice = planPriceDisplay(plan, period); const isLower = PLAN_RANK[plan.id as TenantPlan] < currentRank;
const isEnterprise = plan.id === "enterprise";
const displayPrice = getDisplayPrice(plan);
const prorationAmount = !isCurrent && !isLower ? getProrationAmount(plan.id) : null;
const isLoading = isPending && loadingPlan === plan.id; const isLoading = isPending && loadingPlan === plan.id;
return ( return (
@@ -133,7 +243,7 @@ export function UpgradeSection({
plan.highlight plan.highlight
? "border-primary shadow-md ring-1 ring-primary/20" ? "border-primary shadow-md ring-1 ring-primary/20"
: "" : ""
}`} } ${isLower ? "opacity-50" : ""}`}
> >
{plan.highlight && ( {plan.highlight && (
<div className="absolute -top-3.5 inset-x-0 flex justify-center"> <div className="absolute -top-3.5 inset-x-0 flex justify-center">
@@ -158,6 +268,11 @@ export function UpgradeSection({
{displayPrice.toLocaleString("tr-TR")} {displayPrice.toLocaleString("tr-TR")}
</span> </span>
<span className="text-muted-foreground text-sm mb-1">/ay</span> <span className="text-muted-foreground text-sm mb-1">/ay</span>
{discountApplied !== null && !isCurrent && !isLower && (
<span className="text-muted-foreground text-xs line-through mb-1 ml-1">
{planPriceDisplay(plan, period).toLocaleString("tr-TR")}
</span>
)}
</div> </div>
{period === "yearly" ? ( {period === "yearly" ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -179,18 +294,25 @@ export function UpgradeSection({
</ul> </ul>
{isCurrent ? ( {isCurrent ? (
<div className="space-y-2">
<Button variant="outline" className="w-full" disabled> <Button variant="outline" className="w-full" disabled>
Mevcut Planınız Mevcut Planınız
</Button> </Button>
<button ) : isLower ? (
onClick={handleDowngrade} <Button variant="outline" className="w-full" disabled>
className="w-full text-xs text-muted-foreground hover:text-foreground transition-colors py-1" Plan düşürülemez
</Button>
) : isEnterprise ? (
<Button
className="w-full gap-2"
variant="outline"
onClick={() => setEnterpriseDialogOpen(true)}
disabled={isPending}
> >
Ücretsiz plana geç <Envelope className="h-4 w-4" />
</button> Teklif Al
</div> </Button>
) : ( ) : (
<div className="space-y-1.5">
<Button <Button
className="w-full gap-2" className="w-full gap-2"
variant={plan.highlight ? "default" : "outline"} variant={plan.highlight ? "default" : "outline"}
@@ -204,14 +326,33 @@ export function UpgradeSection({
)} )}
{isLoading ? "Yükleniyor..." : `${plan.name}'a Geç`} {isLoading ? "Yükleniyor..." : `${plan.name}'a Geç`}
</Button> </Button>
{prorationAmount !== null && (
<p className="text-center text-[11px] text-muted-foreground">
Bugün sadece{" "}
<span className="font-medium text-foreground">
{prorationAmount.toLocaleString("tr-TR")}
</span>{" "}
ödersiniz (kalan süre mahsup edilir)
</p>
)}
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
); );
})} })}
</div> </div>
<p className="text-center text-xs text-muted-foreground">
Abonelik iptali için{" "}
<a href="mailto:info@kovakyazilim.com" className="underline hover:text-foreground transition-colors">
info@kovakyazilim.com
</a>{" "}
ile iletişime geçin.
</p>
</div> </div>
{/* PayTR iframe dialog */}
{paytrEnabled && ( {paytrEnabled && (
<Dialog <Dialog
open={dialogOpen} open={dialogOpen}
@@ -243,6 +384,136 @@ export function UpgradeSection({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} )}
{/* Enterprise inquiry dialog */}
<Dialog open={enterpriseDialogOpen} onOpenChange={setEnterpriseDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Crown className="h-5 w-5 text-primary" />
Enterprise Plan Teklifi
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-1">
<p className="text-sm text-muted-foreground">
Ekibimiz sizin için özel fiyatlandırma ve kurulum planı hazırlasın. Birkaç soruyu
yanıtlayın, en kısa sürede size ulaşalım.
</p>
{/* Ekip büyüklüğü */}
<div className="space-y-1.5">
<label className="text-sm font-medium">
Ekibinizdeki danışman sayısı <span className="text-destructive">*</span>
</label>
<div className="grid grid-cols-4 gap-2">
{["1-3", "4-10", "11-25", "25+"].map((opt) => (
<button
key={opt}
type="button"
onClick={() => setEnterpriseForm((f) => ({ ...f, teamSize: opt }))}
className={`rounded-lg border py-2 text-sm font-medium transition-colors ${
enterpriseForm.teamSize === opt
? "border-primary bg-primary/10 text-primary"
: "hover:border-primary/40"
}`}
>
{opt}
</button>
))}
</div>
</div>
{/* Ilan sayısı */}
<div className="space-y-1.5">
<label className="text-sm font-medium">
Aktif ilan sayınız <span className="text-destructive">*</span>
</label>
<div className="grid grid-cols-4 gap-2">
{["1-50", "51-200", "201-500", "500+"].map((opt) => (
<button
key={opt}
type="button"
onClick={() => setEnterpriseForm((f) => ({ ...f, listingCount: opt }))}
className={`rounded-lg border py-2 text-sm font-medium transition-colors ${
enterpriseForm.listingCount === opt
? "border-primary bg-primary/10 text-primary"
: "hover:border-primary/40"
}`}
>
{opt}
</button>
))}
</div>
</div>
{/* Özel modül */}
<div className="space-y-1.5">
<label className="text-sm font-medium">
Özel modül / entegrasyon geliştirmesi istiyor musunuz?{" "}
<span className="text-destructive">*</span>
</label>
<div className="grid grid-cols-3 gap-2">
{[
{ value: "yes", label: "Evet" },
{ value: "no", label: "Hayır" },
{ value: "maybe", label: "Değerlendiriyorum" },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setEnterpriseForm((f) => ({ ...f, needsCustomDev: opt.value }))}
className={`rounded-lg border py-2 text-sm font-medium transition-colors ${
enterpriseForm.needsCustomDev === opt.value
? "border-primary bg-primary/10 text-primary"
: "hover:border-primary/40"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Notlar */}
<div className="space-y-1.5">
<label className="text-sm font-medium">
Eklemek istediğiniz notlar{" "}
<span className="text-muted-foreground font-normal">(isteğe bağlı)</span>
</label>
<textarea
rows={3}
value={enterpriseForm.notes}
onChange={(e) => setEnterpriseForm((f) => ({ ...f, notes: e.target.value }))}
placeholder="Özel ihtiyaçlarınızı, entegrasyon beklentilerinizi veya sorularınızı buraya yazabilirsiniz..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary resize-none"
/>
</div>
<div className="flex gap-2 pt-1">
<Button
variant="outline"
className="flex-1"
onClick={() => setEnterpriseDialogOpen(false)}
disabled={isPending}
>
Vazgeç
</Button>
<Button
className="flex-1 gap-2"
onClick={handleEnterpriseInquiry}
disabled={isPending || !enterpriseForm.teamSize || !enterpriseForm.listingCount || !enterpriseForm.needsCustomDev}
>
{isPending ? (
<CircleNotch className="h-4 w-4 animate-spin" />
) : (
<Envelope className="h-4 w-4" />
)}
Teklif Gönder
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</> </>
); );
} }
@@ -0,0 +1,49 @@
export const dynamic = "force-dynamic";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "@/lib/icons";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { getPaymentHistoryAction } from "@/lib/appwrite/subscription-actions";
import { PaymentHistory } from "../components/payment-history";
export const metadata: Metadata = {
title: "KovakEmlak — Faturalar",
};
export default async function InvoicesPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
if (ctx.role !== "owner") redirect("/dashboard");
const events = await getPaymentHistoryAction();
const officeName = ctx.settings?.officeName ?? "Ofis";
return (
<div className="flex-1 space-y-6 px-6 pt-4">
<div className="flex flex-col gap-1">
<Link
href="/settings/billing"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit mb-1"
>
<ArrowLeft className="h-3.5 w-3.5" />
Plan & Faturalama
</Link>
<p className="text-muted-foreground text-sm">{officeName}</p>
<h1 className="text-2xl font-bold tracking-tight">Faturalar</h1>
<p className="text-muted-foreground text-sm">
Abonelik ödemelerinizin geçmişi.
</p>
</div>
<PaymentHistory events={events} />
</div>
);
}
+13 -10
View File
@@ -2,7 +2,8 @@ export const dynamic = "force-dynamic";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { CheckCircle, XCircle } from '@/lib/icons'; import Link from "next/link";
import { CheckCircle, Receipt } from '@/lib/icons';
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { getEffectivePlan, getPlanUsage } from "@/lib/appwrite/plan-limits"; import { getEffectivePlan, getPlanUsage } from "@/lib/appwrite/plan-limits";
@@ -30,7 +31,6 @@ export default async function BillingPage({
const params = await searchParams; const params = await searchParams;
const upgraded = params.upgraded === "1"; const upgraded = params.upgraded === "1";
const downgraded = params.downgraded === "1";
const plan = getEffectivePlan(ctx); const plan = getEffectivePlan(ctx);
const { usage } = await getPlanUsage(ctx); const { usage } = await getPlanUsage(ctx);
@@ -40,6 +40,7 @@ export default async function BillingPage({
return ( return (
<div className="flex-1 space-y-6 px-6 pt-4"> <div className="flex-1 space-y-6 px-6 pt-4">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{officeName}</p> <p className="text-muted-foreground text-sm">{officeName}</p>
<h1 className="text-2xl font-bold tracking-tight">Plan & Faturalama</h1> <h1 className="text-2xl font-bold tracking-tight">Plan & Faturalama</h1>
@@ -47,18 +48,19 @@ export default async function BillingPage({
Mevcut planınızı görüntüleyin ve yönetin. Mevcut planınızı görüntüleyin ve yönetin.
</p> </p>
</div> </div>
<Link
href="/settings/billing/invoices"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0 mt-1"
>
<Receipt className="h-4 w-4" />
Faturalar
</Link>
</div>
{upgraded && ( {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"> <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">
<CheckCircle className="h-4 w-4 shrink-0" /> <CheckCircle className="h-4 w-4 shrink-0" />
Tebrikler! Planınız başarıyla yükseltildi. KovakEmlak Pro&apos;ya hoş geldiniz. Tebrikler! Planınız başarıyla yükseltildi.
</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>
)} )}
@@ -72,6 +74,7 @@ export default async function BillingPage({
<UpgradeSection <UpgradeSection
currentPlan={plan} currentPlan={plan}
currentPeriod={ctx.settings?.planPeriod ?? null} currentPeriod={ctx.settings?.planPeriod ?? null}
planExpiresAt={ctx.settings?.planExpiresAt ?? null}
paytrEnabled={paytrEnabled} paytrEnabled={paytrEnabled}
/> />
</div> </div>
+4 -1
View File
@@ -26,7 +26,10 @@ export async function POST(req: Request): Promise<Response> {
return new Response("FAILED", { status: 400 }); return new Response("FAILED", { status: 400 });
} }
try { try {
await activatePlanInDb(tenantId, plan, "paytr", period); // totalAmount kuruş cinsinden gelir → TRY'ye çevir
const amountTRY = Math.round(Number(totalAmount) / 100);
const orderId = merchantOid;
await activatePlanInDb(tenantId, plan, "paytr", period, { amount: amountTRY, orderId });
} catch (e) { } catch (e) {
console.error("[paytr-callback]", e); console.error("[paytr-callback]", e);
return new Response("FAILED", { status: 500 }); return new Response("FAILED", { status: 500 });
+9 -1
View File
@@ -29,6 +29,7 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import type { Icon } from "@/lib/icons"; import type { Icon } from "@/lib/icons";
@@ -145,6 +146,10 @@ const navGroups: NavGroup[] = [
url: "/settings/billing", url: "/settings/billing",
icon: CreditCard, icon: CreditCard,
minRole: "owner" as ShellRole, minRole: "owner" as ShellRole,
items: [
{ title: "Plan & Yükseltme", url: "/settings/billing" },
{ title: "Faturalar", url: "/settings/billing/invoices" },
],
}, },
], ],
}, },
@@ -162,6 +167,9 @@ export function AppSidebar({
pendingMatchCount?: number; pendingMatchCount?: number;
role?: ShellRole; role?: ShellRole;
}) { }) {
const { isMobile, setOpenMobile } = useSidebar();
const closeMobile = () => { if (isMobile) setOpenMobile(false) };
const groups = navGroups const groups = navGroups
.map((group) => ({ .map((group) => ({
...group, ...group,
@@ -184,7 +192,7 @@ export function AppSidebar({
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton size="lg" asChild> <SidebarMenuButton size="lg" asChild>
<Link href="/dashboard"> <Link href="/dashboard" onClick={closeMobile}>
{company.logoUrl ? ( {company.logoUrl ? (
<div className="bg-background flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg border"> <div className="bg-background flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg border">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
+5 -1
View File
@@ -18,6 +18,7 @@ import {
SidebarMenuSub, SidebarMenuSub,
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
useSidebar,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
export function NavMain({ export function NavMain({
@@ -39,6 +40,8 @@ export function NavMain({
}[] }[]
}) { }) {
const pathname = usePathname() const pathname = usePathname()
const { isMobile, setOpenMobile } = useSidebar()
const closeMobile = () => { if (isMobile) setOpenMobile(false) }
// Returns the url of the best-matching subitem (longest prefix wins). // Returns the url of the best-matching subitem (longest prefix wins).
// Handles detail pages: /customers/123 correctly activates the /customers subitem // Handles detail pages: /customers/123 correctly activates the /customers subitem
@@ -89,6 +92,7 @@ export function NavMain({
<SidebarMenuSubButton asChild className="cursor-pointer" isActive={activeSubUrl === subItem.url}> <SidebarMenuSubButton asChild className="cursor-pointer" isActive={activeSubUrl === subItem.url}>
<Link <Link
href={subItem.url} href={subItem.url}
onClick={closeMobile}
target={(item.title === "Auth Pages" || item.title === "Errors") ? "_blank" : undefined} target={(item.title === "Auth Pages" || item.title === "Errors") ? "_blank" : undefined}
rel={(item.title === "Auth Pages" || item.title === "Errors") ? "noopener noreferrer" : undefined} rel={(item.title === "Auth Pages" || item.title === "Errors") ? "noopener noreferrer" : undefined}
> >
@@ -108,7 +112,7 @@ export function NavMain({
) )
})() : ( })() : (
<SidebarMenuButton asChild tooltip={item.title} className="cursor-pointer" isActive={pathname === item.url || pathname.startsWith(item.url + "/")}> <SidebarMenuButton asChild tooltip={item.title} className="cursor-pointer" isActive={pathname === item.url || pathname.startsWith(item.url + "/")}>
<Link href={item.url}> <Link href={item.url} onClick={closeMobile}>
{item.icon && <item.icon />} {item.icon && <item.icon />}
<span>{item.title}</span> <span>{item.title}</span>
</Link> </Link>
+5 -4
View File
@@ -37,7 +37,8 @@ export function NavUser({
}: { }: {
user: { name: string; email: string }; user: { name: string; email: string };
}) { }) {
const { isMobile } = useSidebar(); const { isMobile, setOpenMobile } = useSidebar();
const closeMobile = () => { if (isMobile) setOpenMobile(false) };
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const handleSignOut = () => { const handleSignOut = () => {
@@ -85,19 +86,19 @@ export function NavUser({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings/account"> <Link href="/settings/account" onClick={closeMobile}>
<UserCircle /> <UserCircle />
Profil Profil
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings/billing"> <Link href="/settings/billing" onClick={closeMobile}>
<CreditCard /> <CreditCard />
Plan & Faturalama Plan & Faturalama
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings/notifications"> <Link href="/settings/notifications" onClick={closeMobile}>
<BellSimple /> <BellSimple />
Bildirimler Bildirimler
</Link> </Link>
+11
View File
@@ -17,6 +17,7 @@ export const TABLES = {
inviteLinks: "invite_links", inviteLinks: "invite_links",
deals: "deals", deals: "deals",
passwordResets: "password_resets", passwordResets: "password_resets",
paymentEvents: "payment_events",
} as const; } as const;
export type TableId = (typeof TABLES)[keyof typeof TABLES]; export type TableId = (typeof TABLES)[keyof typeof TABLES];
@@ -211,6 +212,16 @@ export interface TenantSettings extends Row {
planProvider?: string; planProvider?: string;
} }
export interface PaymentEvent extends Row {
tenantId: string;
plan: Exclude<TenantPlan, "free">;
period: PlanPeriod;
amount: number;
provider: string;
orderId?: string;
discountCode?: string;
}
export type PropertyFeature = export type PropertyFeature =
| "balkon" | "balkon"
| "otopark" | "otopark"
+156 -20
View File
@@ -2,13 +2,13 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Query } from "node-appwrite"; import { ID, Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type TenantPlan, type PlanPeriod } from "./schema"; import { DATABASE_ID, TABLES, type TenantPlan, type PlanPeriod, type PaymentEvent } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard"; import { requireRole, requireTenant } from "./tenant-guard";
import { getEffectivePlan } from "./plan-limits"; import { getEffectivePlan } from "./plan-limits";
import { PLAN_CATALOG, planPrice } from "./subscription-types"; import { PLAN_CATALOG, PLAN_RANK, planPrice, validateDiscountCode } from "./subscription-types";
import { getShopierPlanUrl, isShopierEnabled } from "../payments/shopier"; import { getShopierPlanUrl, isShopierEnabled } from "../payments/shopier";
import { createPolarCheckout, isPolarEnabled } from "../payments/polar"; import { createPolarCheckout, isPolarEnabled } from "../payments/polar";
import { getPayTRToken } from "../payments/paytr"; import { getPayTRToken } from "../payments/paytr";
@@ -25,6 +25,7 @@ export async function activatePlanInDb(
plan: TenantPlan, plan: TenantPlan,
provider: string, provider: string,
period: PlanPeriod = "monthly", period: PlanPeriod = "monthly",
opts?: { amount?: number; orderId?: string; discountCode?: string },
): Promise<void> { ): Promise<void> {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
@@ -46,6 +47,24 @@ export async function activatePlanInDb(
planExpiresAt: expires.toISOString(), planExpiresAt: expires.toISOString(),
planProvider: provider, planProvider: provider,
}); });
// Ödeme geçmişi kaydı
if (plan !== "free" && opts?.amount != null) {
try {
const eventData: Record<string, unknown> = {
tenantId,
plan,
period,
amount: opts.amount,
provider,
};
if (opts.orderId) eventData.orderId = opts.orderId;
if (opts.discountCode) eventData.discountCode = opts.discountCode;
await tablesDB.createRow(DATABASE_ID, TABLES.paymentEvents, ID.unique(), eventData);
} catch {
// best-effort — ödeme aktivasyonunu bloke etme
}
}
} }
export async function deactivatePlanInDb(tenantId: string): Promise<void> { export async function deactivatePlanInDb(tenantId: string): Promise<void> {
@@ -64,17 +83,45 @@ export async function deactivatePlanInDb(tenantId: string): Promise<void> {
}); });
} }
function assertUpgrade(currentPlan: TenantPlan, targetPlan: TenantPlan) {
if (PLAN_RANK[targetPlan] <= PLAN_RANK[currentPlan]) {
throw new Error("Yalnızca daha üst bir plana geçiş yapabilirsiniz.");
}
}
/** Returns prorated price to charge when upgrading mid-cycle. Min 1 TRY. */
function calculateProration(
currentPlan: TenantPlan,
currentPeriod: PlanPeriod,
planExpiresAt: string | undefined | null,
newPlanPrice: number,
): number {
if (!planExpiresAt || currentPlan === "free") return newPlanPrice;
const currentEntry = PLAN_CATALOG[currentPlan as Exclude<TenantPlan, "free">];
if (!currentEntry) return newPlanPrice;
const periodDays = currentPeriod === "yearly" ? 365 : 30;
const msRemaining = new Date(planExpiresAt).getTime() - Date.now();
const daysRemaining = Math.max(0, msRemaining / (1000 * 60 * 60 * 24));
const unusedFraction = Math.min(1, daysRemaining / periodDays);
const credit = planPrice(currentEntry, currentPeriod) * unusedFraction;
return Math.max(1, Math.round(newPlanPrice - credit));
}
// ── Mock checkout (geliştirme ortamı) ───────────────────────────────────────── // ── Mock checkout (geliştirme ortamı) ─────────────────────────────────────────
export async function startMockCheckoutAction(formData: FormData): Promise<void> { export async function startMockCheckoutAction(formData: FormData): Promise<void> {
const plan = String(formData.get("plan") ?? "") as TenantPlan; const plan = String(formData.get("plan") ?? "") as TenantPlan;
if (plan !== "pro") throw new Error("Geçersiz plan."); if (!["starter", "pro", "enterprise"].includes(plan)) throw new Error("Geçersiz plan.");
const ctx = await requireTenant(); const ctx = await requireTenant();
requireRole(ctx, ["owner"]); requireRole(ctx, ["owner"]);
assertUpgrade(getEffectivePlan(ctx), plan as TenantPlan);
// Mock: direkt aktive et const catalog = PLAN_CATALOG[plan as Exclude<TenantPlan, "free">];
await activatePlanInDb(ctx.tenantId, plan, "mock"); const period = String(formData.get("period") ?? "monthly") as PlanPeriod;
const amount = planPrice(catalog, period);
await activatePlanInDb(ctx.tenantId, plan as TenantPlan, "mock", period, { amount });
redirect("/settings/billing?upgraded=1"); redirect("/settings/billing?upgraded=1");
} }
@@ -82,15 +129,15 @@ export async function startMockCheckoutAction(formData: FormData): Promise<void>
export async function startShopierCheckoutAction(formData: FormData): Promise<void> { export async function startShopierCheckoutAction(formData: FormData): Promise<void> {
const plan = String(formData.get("plan") ?? "") as TenantPlan; const plan = String(formData.get("plan") ?? "") as TenantPlan;
if (plan !== "pro") throw new Error("Geçersiz plan."); if (!["starter", "pro"].includes(plan)) throw new Error("Geçersiz plan.");
const ctx = await requireTenant(); const ctx = await requireTenant();
requireRole(ctx, ["owner"]); requireRole(ctx, ["owner"]);
assertUpgrade(getEffectivePlan(ctx), plan as TenantPlan);
const storeUrl = getShopierPlanUrl(plan); const storeUrl = getShopierPlanUrl(plan);
if (!storeUrl) throw new Error("Shopier mağaza URL'i ayarlanmamış."); if (!storeUrl) throw new Error("Shopier mağaza URL'i ayarlanmamış.");
// Shopier mağaza sayfasına yönlendir — ödeme tamamlanınca webhook gelir
redirect(storeUrl); redirect(storeUrl);
} }
@@ -98,12 +145,13 @@ export async function startShopierCheckoutAction(formData: FormData): Promise<vo
export async function startPolarCheckoutAction(formData: FormData): Promise<void> { export async function startPolarCheckoutAction(formData: FormData): Promise<void> {
const plan = String(formData.get("plan") ?? "") as TenantPlan; const plan = String(formData.get("plan") ?? "") as TenantPlan;
if (plan !== "pro") throw new Error("Geçersiz plan."); if (!["starter", "pro"].includes(plan)) throw new Error("Geçersiz plan.");
const ctx = await requireTenant(); const ctx = await requireTenant();
requireRole(ctx, ["owner"]); requireRole(ctx, ["owner"]);
assertUpgrade(getEffectivePlan(ctx), plan as TenantPlan);
const catalog = PLAN_CATALOG[plan]; const catalog = PLAN_CATALOG[plan as Exclude<TenantPlan, "free">];
const orderId = generateOrderId(); const orderId = generateOrderId();
const appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3001"; const appUrl = process.env.APP_URL?.replace(/\/$/, "") ?? "http://localhost:3001";
@@ -114,9 +162,7 @@ export async function startPolarCheckoutAction(formData: FormData): Promise<void
successUrl: `${appUrl}/settings/billing?upgraded=1`, successUrl: `${appUrl}/settings/billing?upgraded=1`,
}); });
// orderId'yi tenant_settings'e geçici olarak kaydedebiliriz ama void catalog;
// minimal yaklaşımda metadata.tenant_id yeterli — webhook okur
void catalog; // fiyat bilgisi ileride log için kullanılabilir
redirect(checkout.url); redirect(checkout.url);
} }
@@ -125,6 +171,7 @@ export async function startPolarCheckoutAction(formData: FormData): Promise<void
export async function getPayTRTokenAction(formData: FormData): Promise<string> { export async function getPayTRTokenAction(formData: FormData): Promise<string> {
const planId = String(formData.get("plan") ?? "") as Exclude<TenantPlan, "free">; const planId = String(formData.get("plan") ?? "") as Exclude<TenantPlan, "free">;
const period = String(formData.get("period") ?? "monthly") as PlanPeriod; const period = String(formData.get("period") ?? "monthly") as PlanPeriod;
const discountCodeRaw = String(formData.get("discountCode") ?? "").trim();
if (!["starter", "pro", "enterprise"].includes(planId)) throw new Error("Geçersiz plan."); if (!["starter", "pro", "enterprise"].includes(planId)) throw new Error("Geçersiz plan.");
if (!["monthly", "yearly"].includes(period)) throw new Error("Geçersiz dönem."); if (!["monthly", "yearly"].includes(period)) throw new Error("Geçersiz dönem.");
@@ -132,8 +179,26 @@ export async function getPayTRTokenAction(formData: FormData): Promise<string> {
const ctx = await requireTenant(); const ctx = await requireTenant();
requireRole(ctx, ["owner"]); requireRole(ctx, ["owner"]);
const currentPlan = getEffectivePlan(ctx);
assertUpgrade(currentPlan, planId as TenantPlan);
const catalog = PLAN_CATALOG[planId]; const catalog = PLAN_CATALOG[planId];
const price = planPrice(catalog, period); let price = planPrice(catalog, period);
// Proration: credit for unused days on current paid plan
price = calculateProration(
currentPlan,
ctx.settings?.planPeriod ?? "monthly",
ctx.settings?.planExpiresAt,
price,
);
// Discount code
if (discountCodeRaw) {
const discountFraction = validateDiscountCode(discountCodeRaw);
if (!discountFraction) throw new Error("Geçersiz indirim kodu.");
price = Math.max(1, Math.round(price * (1 - discountFraction)));
}
// APP_URL: server-to-server callback (HTTPS zorunlu, prod'da veya ngrok) // APP_URL: server-to-server callback (HTTPS zorunlu, prod'da veya ngrok)
// APP_BROWSER_URL: browser redirect (dev'de localhost, prod'da aynı domain) // APP_BROWSER_URL: browser redirect (dev'de localhost, prod'da aynı domain)
@@ -152,9 +217,10 @@ export async function getPayTRTokenAction(formData: FormData): Promise<string> {
// Format: {tenantId}T{timestamp}{random}P{plan}X{period} // Format: {tenantId}T{timestamp}{random}P{plan}X{period}
const merchantOid = `${ctx.tenantId}T${timestamp}${random}P${planId}X${period}`; const merchantOid = `${ctx.tenantId}T${timestamp}${random}P${planId}X${period}`;
const discountLabel = discountCodeRaw ? ` + ${discountCodeRaw.toUpperCase()} İndirimi` : "";
const userBasket: Array<[string, string, number]> = [ const userBasket: Array<[string, string, number]> = [
[ [
`KovakEmlak ${catalog.name} (${period === "yearly" ? "Yıllık" : "Aylık"})`, `KovakEmlak ${catalog.name} (${period === "yearly" ? "Yıllık" : "Aylık"})${discountLabel}`,
price.toFixed(2), price.toFixed(2),
1, 1,
], ],
@@ -177,7 +243,7 @@ export async function getPayTRTokenAction(formData: FormData): Promise<string> {
}); });
} }
// ── Unified entry point ──────────────────────────────────────────────────────── // ── Unified entry point (Polar/Shopier only — PayTR uses getPayTRTokenAction) ──
export async function startCheckoutAction(formData: FormData): Promise<void> { export async function startCheckoutAction(formData: FormData): Promise<void> {
if (isPolarEnabled()) return startPolarCheckoutAction(formData); if (isPolarEnabled()) return startPolarCheckoutAction(formData);
@@ -185,13 +251,63 @@ export async function startCheckoutAction(formData: FormData): Promise<void> {
return startMockCheckoutAction(formData); return startMockCheckoutAction(formData);
} }
// ── Downgrade ────────────────────────────────────────────────────────────────── // ── Enterprise inquiry ─────────────────────────────────────────────────────────
export async function downgradeToFreeAction(): Promise<void> { export async function requestEnterpriseInquiryAction(
formData: FormData,
): Promise<{ ok: boolean; error?: string }> {
const ctx = await requireTenant(); const ctx = await requireTenant();
requireRole(ctx, ["owner"]); requireRole(ctx, ["owner"]);
await deactivatePlanInDb(ctx.tenantId);
redirect("/settings/billing?downgraded=1"); const officeName = ctx.settings?.officeName ?? ctx.user.name ?? "Bilinmiyor";
const userEmail = ctx.user.email;
const tenantId = ctx.tenantId;
const teamSize = String(formData.get("teamSize") ?? "").trim();
const listingCount = String(formData.get("listingCount") ?? "").trim();
const needsCustomDev = String(formData.get("needsCustomDev") ?? "").trim();
const notes = String(formData.get("notes") ?? "").trim();
if (!teamSize || !listingCount || !needsCustomDev) {
return { ok: false, error: "Lütfen tüm zorunlu alanları doldurun." };
}
const customDevLabel = needsCustomDev === "yes" ? "Evet" : needsCustomDev === "no" ? "Hayır" : "Belirsiz";
try {
const { messaging } = createAdminClient();
// Confirmation email to the tenant owner
await messaging.createEmail(
`enterprise-inquiry-${tenantId}-${Date.now()}`,
"Enterprise Plan Talebiniz Alındı — KovakEmlak",
`<p>Merhaba ${officeName},</p>
<p>Enterprise plan talebiniz alındı. Ekibimiz en kısa sürede <strong>${userEmail}</strong> adresine size ulaşacak.</p>
<table style="border-collapse:collapse;margin:16px 0;font-size:14px;">
<tr><td style="padding:6px 12px 6px 0;color:#888;">Ofis adı</td><td style="padding:6px 0"><strong>${officeName}</strong></td></tr>
<tr><td style="padding:6px 12px 6px 0;color:#888;">Ekip büyüklüğü</td><td style="padding:6px 0"><strong>${teamSize} kişi</strong></td></tr>
<tr><td style="padding:6px 12px 6px 0;color:#888;">Aktif ilan sayısı</td><td style="padding:6px 0"><strong>${listingCount}</strong></td></tr>
<tr><td style="padding:6px 12px 6px 0;color:#888;">Özel modül geliştirme</td><td style="padding:6px 0"><strong>${customDevLabel}</strong></td></tr>
${notes ? `<tr><td style="padding:6px 12px 6px 0;color:#888;vertical-align:top;">Notlar</td><td style="padding:6px 0">${notes}</td></tr>` : ""}
</table>
<p style="color:#888;font-size:12px;">Bu e-posta otomatik olarak gönderilmiştir.</p>`,
[],
[ctx.user.id],
[],
);
} catch {
// Email gönderimi başarısız olsa bile talebi kabul et
}
return { ok: true };
}
// ── Downgrade (engellendi) ─────────────────────────────────────────────────────
export async function downgradeToFreeAction(): Promise<void> {
// Ücretli plandan ücretsiz plana geçiş devre dışı.
// Abonelik iptali için destek hattıyla iletişime geçin.
throw new Error("Ücretli plandan ücretsiz plana geçiş yapılamaz. Abonelik iptali için info@kovakyazilim.com ile iletişime geçin.");
} }
// ── Mevcut plan bilgisi ──────────────────────────────────────────────────────── // ── Mevcut plan bilgisi ────────────────────────────────────────────────────────
@@ -209,3 +325,23 @@ export async function getCurrentPlanAction(): Promise<{
provider: ctx.settings?.planProvider ?? null, provider: ctx.settings?.planProvider ?? null,
}; };
} }
// ── Ödeme geçmişi (sadece owner) ──────────────────────────────────────────────
export async function getPaymentHistoryAction(): Promise<PaymentEvent[]> {
const ctx = await requireTenant();
requireRole(ctx, ["owner"]);
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.paymentEvents,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.orderDesc("$createdAt"),
Query.limit(50),
],
});
return result.rows as unknown as PaymentEvent[];
}
+18
View File
@@ -1,5 +1,23 @@
import type { TenantPlan, PlanPeriod } from "./schema"; import type { TenantPlan, PlanPeriod } from "./schema";
export const PLAN_RANK: Record<TenantPlan, number> = {
free: 0,
starter: 1,
pro: 2,
enterprise: 3,
};
// Validated server-side; values are discount fractions (0.5 = 50% off)
export const DISCOUNT_CODES: Record<string, number> = {
KOVAK50: 0.5,
KOVAK10: 0.1,
};
export function validateDiscountCode(code: string): number | null {
const normalized = code.trim().toUpperCase();
return DISCOUNT_CODES[normalized] ?? null;
}
export type PlanCatalogEntry = { export type PlanCatalogEntry = {
id: TenantPlan; id: TenantPlan;
name: string; name: string;