diff --git a/src/app/(dashboard)/calendar/components/event-form-sheet.tsx b/src/app/(dashboard)/calendar/components/event-form-sheet.tsx
index b33cbc2..9186d41 100644
--- a/src/app/(dashboard)/calendar/components/event-form-sheet.tsx
+++ b/src/app/(dashboard)/calendar/components/event-form-sheet.tsx
@@ -225,7 +225,7 @@ export function EventFormSheet({
-
+
{isEdit && event && onRequestDelete && (
diff --git a/src/app/(dashboard)/customers/components/customer-form-sheet.tsx b/src/app/(dashboard)/customers/components/customer-form-sheet.tsx
index 530e5d9..e02c1b7 100644
--- a/src/app/(dashboard)/customers/components/customer-form-sheet.tsx
+++ b/src/app/(dashboard)/customers/components/customer-form-sheet.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useActionState, useEffect } from "react";
+import { useActionState, useEffect, useState } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
@@ -23,6 +23,7 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
+import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
import {
createCustomerAction,
updateCustomerAction,
@@ -40,11 +41,14 @@ export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
const isEdit = Boolean(customer);
const action = isEdit ? updateCustomerAction : createCustomerAction;
const [state, formAction, isPending] = useActionState(action, initialCustomerState);
+ const [planLimitOpen, setPlanLimitOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Müşteri güncellendi." : "Müşteri eklendi.");
onOpenChange(false);
+ } else if (state.code === "PLAN_LIMIT_EXCEEDED") {
+ setPlanLimitOpen(true);
} else if (state.error) {
toast.error(state.error);
}
@@ -155,7 +159,7 @@ export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
-
+
+
);
}
diff --git a/src/app/(dashboard)/customers/page.tsx b/src/app/(dashboard)/customers/page.tsx
index f164fec..2025b2f 100644
--- a/src/app/(dashboard)/customers/page.tsx
+++ b/src/app/(dashboard)/customers/page.tsx
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
+import { UsageBanner } from "@/components/billing/usage-banner";
import { listCustomers } from "@/lib/appwrite/customer-queries";
+import { getPlanUsage } from "@/lib/appwrite/plan-limits";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { CustomersClient } from "./components/customers-client";
@@ -17,7 +19,10 @@ export default async function CustomersPage() {
redirect("/onboarding");
}
- const customers = await listCustomers(ctx.tenantId);
+ const [customers, usage] = await Promise.all([
+ listCustomers(ctx.tenantId),
+ getPlanUsage(ctx),
+ ]);
return (
@@ -29,6 +34,8 @@ export default async function CustomersPage() {
+
+
({
id: c.$id,
diff --git a/src/app/(dashboard)/dashboard-shell.tsx b/src/app/(dashboard)/dashboard-shell.tsx
index 66a3bf5..c81e77e 100644
--- a/src/app/(dashboard)/dashboard-shell.tsx
+++ b/src/app/(dashboard)/dashboard-shell.tsx
@@ -18,6 +18,7 @@ export type ShellUser = {
export type ShellCompany = {
id: string;
name: string;
+ logoUrl?: string | null;
};
export function DashboardShell({
diff --git a/src/app/(dashboard)/finance/banks/components/bank-form-sheet.tsx b/src/app/(dashboard)/finance/banks/components/bank-form-sheet.tsx
index ab6c8dd..28cec34 100644
--- a/src/app/(dashboard)/finance/banks/components/bank-form-sheet.tsx
+++ b/src/app/(dashboard)/finance/banks/components/bank-form-sheet.tsx
@@ -130,7 +130,7 @@ export function BankFormSheet({ open, onOpenChange, account }: Props) {
-
+
-
+
onOpenChange(false)} disabled={isPending}>
Vazgeç
diff --git a/src/app/(dashboard)/finance/cards/components/statement-form-sheet.tsx b/src/app/(dashboard)/finance/cards/components/statement-form-sheet.tsx
index 4ded34d..81f166f 100644
--- a/src/app/(dashboard)/finance/cards/components/statement-form-sheet.tsx
+++ b/src/app/(dashboard)/finance/cards/components/statement-form-sheet.tsx
@@ -170,7 +170,7 @@ export function StatementFormSheet({
-
+
onOpenChange(false)} disabled={isPending}>
Vazgeç
diff --git a/src/app/(dashboard)/finance/components/finance-form-sheet.tsx b/src/app/(dashboard)/finance/components/finance-form-sheet.tsx
index 4545758..ed7d806 100644
--- a/src/app/(dashboard)/finance/components/finance-form-sheet.tsx
+++ b/src/app/(dashboard)/finance/components/finance-form-sheet.tsx
@@ -1,9 +1,11 @@
"use client";
-import { useActionState, useEffect } from "react";
+import { useActionState, useEffect, useState } from "react";
import { Loader2, Save, Trash2 } from "lucide-react";
import { toast } from "sonner";
+import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
+
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -61,11 +63,14 @@ export function FinanceFormSheet({
const isEdit = Boolean(entry);
const action = isEdit ? updateFinanceEntryAction : createFinanceEntryAction;
const [state, formAction, isPending] = useActionState(action, initialFinanceState);
+ const [planLimitOpen, setPlanLimitOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Kayıt güncellendi." : "Kayıt eklendi.");
onOpenChange(false);
+ } else if (state.code === "PLAN_LIMIT_EXCEEDED") {
+ setPlanLimitOpen(true);
} else if (state.error) {
toast.error(state.error);
}
@@ -216,7 +221,7 @@ export function FinanceFormSheet({
-
+
{isEdit && entry && onRequestDelete && (
@@ -260,6 +265,11 @@ export function FinanceFormSheet({
+
);
}
diff --git a/src/app/(dashboard)/finance/loans/components/loan-form-sheet.tsx b/src/app/(dashboard)/finance/loans/components/loan-form-sheet.tsx
index 39be8f8..a3e484a 100644
--- a/src/app/(dashboard)/finance/loans/components/loan-form-sheet.tsx
+++ b/src/app/(dashboard)/finance/loans/components/loan-form-sheet.tsx
@@ -230,7 +230,7 @@ export function LoanFormSheet({
-
+
onOpenChange(false)} disabled={isPending}>
Vazgeç
diff --git a/src/app/(dashboard)/invoices/[id]/components/items-editor.tsx b/src/app/(dashboard)/invoices/[id]/components/items-editor.tsx
index 52b3157..809965f 100644
--- a/src/app/(dashboard)/invoices/[id]/components/items-editor.tsx
+++ b/src/app/(dashboard)/invoices/[id]/components/items-editor.tsx
@@ -271,7 +271,7 @@ function ItemFormSheet({
-
+
-
+
- {/* Pricing Cards */}
-
-
+
+
+
{ctx.settings?.companyName ?? "Çalışma alanı"}
+
Plan
+
+ İşletmem'i ölçeğine göre kullan. Sektörel paketler (Kliniğim, Ajansım) yakında.
+
+
+
+
+
+ Bu ayki kullanımın
+
+ Mevcut planın sınırlarına ne kadar yaklaştığını gör.
+
+
+
+ {resources.map((r) => {
+ const u = usage.usage[r];
+ const pct =
+ u.limit === Number.POSITIVE_INFINITY
+ ? 0
+ : Math.min(100, Math.round((u.used / Math.max(1, u.limit)) * 100));
+ const limitLabel =
+ u.limit === Number.POSITIVE_INFINITY ? "∞" : String(u.limit);
+ return (
+
+
+ {RESOURCE_LABELS[r]}
+
+ {u.used} / {limitLabel}
+
+
+ {u.limit !== Number.POSITIVE_INFINITY && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
İşletmem planları
+
+ Tek para birimi: ₺ (TRY)
+
+
+
+
+ {tiers.map((tier) => (
+
+ {tier.isCurrent && (
+
+
+
+ Mevcut plan
+
+
+ )}
+ {tier.isPopular && !tier.isCurrent && (
+
+
+
+ Önerilen
+
+
+ )}
+
+ {tier.name}
+ {tier.description}
+
+
+
+ {trFmt.format(tier.price)}
+ /ay
+
+
+ {tier.features.map((feature) => (
+
+ ))}
+
+
+
+ {tier.isCurrent ? (
+
+ Mevcut plan
+
+ ) : !canManage ? (
+
+ Sahip yetkisi gerekli
+
+ ) : tier.id === "pro" ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
- {/* Features Section */}
-
+
+
+
Ekosistem paketleri
+
+
+ Yakında
+
+
+
+ Sektörel modüller İşletmem'in üzerine eklenecek. Aynı hesabınla farklı şirketleri tek
+ panelden yöneteceksin.
+
- {/* FAQ Section */}
-
+
+ {ECOSYSTEM_TIERS.map((t) => (
+
+
+
+ {t.name}
+ {t.description}
+
+
+
+ {t.features.map((feature) => (
+
+ ))}
+
+
+
+
+ Geliştirme aşamasında
+
+
+
+ ))}
+
+
+
+
+
+
+ Test modu: Pro plan şu anda mock
+ ödeme akışıyla çalışır. Shopier entegrasyonu yakında — gerçek tahsilat ancak entegrasyon
+ tamamlandıktan sonra başlayacak.
+
+
+
- )
+ );
}
diff --git a/src/app/(dashboard)/services/components/service-form-sheet.tsx b/src/app/(dashboard)/services/components/service-form-sheet.tsx
index c234238..dab67de 100644
--- a/src/app/(dashboard)/services/components/service-form-sheet.tsx
+++ b/src/app/(dashboard)/services/components/service-form-sheet.tsx
@@ -172,7 +172,7 @@ export function ServiceFormSheet({
-
+
VISA;
+ }
+ if (brand === "mastercard") {
+ return (
+
+ );
+ }
+ if (brand === "amex") {
+ return AMEX ;
+ }
+ if (brand === "troy") {
+ return troy ;
+ }
+ return (
+
+ );
+}
+
+function maskedNumber(num: string): string {
+ const digits = num.replace(/\D/g, "").slice(0, 16);
+ const padded = digits.padEnd(16, "•");
+ return padded.match(/.{1,4}/g)?.join(" ") ?? "";
+}
+
+type Props = {
+ number: string;
+ name: string;
+ expiry: string;
+ cvc: string;
+ flipped: boolean;
+};
+
+export function CreditCardVisual({ number, name, expiry, cvc, flipped }: Props) {
+ const brand = detectBrand(number);
+ const display = maskedNumber(number);
+ const cvcDisplay = cvc.padEnd(3, "•").slice(0, 4);
+
+ return (
+
+
+ {/* FRONT */}
+
+
+
+
+
+
+
+
+ {display}
+
+
+
+
+ Kart sahibi
+
+
+ {name || "AD SOYAD"}
+
+
+
+
+ Son kullanma
+
+
{expiry || "AA/YY"}
+
+
+
+
+
+
+ {/* BACK */}
+
+
+
+
+
+ {cvcDisplay}
+
+
+ CVC
+
+
+
+ Bu kart yalnızca İşletmem mock test akışı içindir. Gerçek bir banka kartı
+ değildir, hiçbir tahsilat yapılmaz.
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/settings/billing/checkout/[orderId]/components/mock-payment-form.tsx b/src/app/(dashboard)/settings/billing/checkout/[orderId]/components/mock-payment-form.tsx
new file mode 100644
index 0000000..e2c4091
--- /dev/null
+++ b/src/app/(dashboard)/settings/billing/checkout/[orderId]/components/mock-payment-form.tsx
@@ -0,0 +1,379 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import Link from "next/link";
+import { ArrowLeft, CreditCard, Loader2, Lock, ShieldCheck, X } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { cn } from "@/lib/utils";
+import {
+ cancelMockPaymentAction,
+ confirmMockPaymentAction,
+} from "@/lib/appwrite/subscription-actions";
+import type { CardBrand, SavedCard } from "@/lib/appwrite/schema";
+
+import { CreditCardVisual } from "./credit-card-visual";
+
+const trFmt = new Intl.NumberFormat("tr-TR", {
+ style: "currency",
+ currency: "TRY",
+ maximumFractionDigits: 0,
+});
+
+const BRAND_LABEL: Record = {
+ visa: "Visa",
+ mastercard: "Mastercard",
+ amex: "Amex",
+ troy: "troy",
+ unknown: "Kart",
+};
+
+function formatNumber(v: string): string {
+ return v
+ .replace(/\D/g, "")
+ .slice(0, 16)
+ .replace(/(.{4})/g, "$1 ")
+ .trim();
+}
+
+function formatExpiry(v: string): string {
+ const digits = v.replace(/\D/g, "").slice(0, 4);
+ if (digits.length < 3) return digits;
+ return `${digits.slice(0, 2)}/${digits.slice(2)}`;
+}
+
+function formatCvc(v: string): string {
+ return v.replace(/\D/g, "").slice(0, 4);
+}
+
+function detectBrand(num: string): CardBrand {
+ const n = num.replace(/\s/g, "");
+ if (/^4/.test(n)) return "visa";
+ if (/^(5[1-5]|2[2-7])/.test(n)) return "mastercard";
+ if (/^3[47]/.test(n)) return "amex";
+ if (/^(9792|65)/.test(n)) return "troy";
+ return "unknown";
+}
+
+type Props = {
+ orderId: string;
+ amount: number;
+ planName: string;
+ planPeriod: string;
+ savedCards: SavedCard[];
+};
+
+export function MockPaymentForm({
+ orderId,
+ amount,
+ planName,
+ planPeriod,
+ savedCards,
+}: Props) {
+ const defaultSaved = savedCards.find((c) => c.isDefault) ?? savedCards[0] ?? null;
+ const [mode, setMode] = useState<"saved" | "new">(defaultSaved ? "saved" : "new");
+ const [selectedCardId, setSelectedCardId] = useState(defaultSaved?.$id ?? "");
+
+ const [number, setNumber] = useState("");
+ const [name, setName] = useState("");
+ const [expiry, setExpiry] = useState("");
+ const [cvc, setCvc] = useState("");
+ const [flipped, setFlipped] = useState(false);
+ const [saveCard, setSaveCard] = useState(true);
+
+ const [confirming, startConfirm] = useTransition();
+ const [cancelling, startCancel] = useTransition();
+
+ const numberDigits = number.replace(/\s/g, "");
+ const expiryDigits = expiry.replace(/\D/g, "");
+
+ const newCardFilled =
+ numberDigits.length === 16 &&
+ name.trim().length >= 3 &&
+ expiryDigits.length === 4 &&
+ cvc.length >= 3;
+
+ const filled = mode === "saved" ? Boolean(selectedCardId) : newCardFilled;
+
+ const handleConfirm = () => {
+ const fd = new FormData();
+ fd.set("orderId", orderId);
+
+ if (mode === "saved" && selectedCardId) {
+ fd.set("savedCardId", selectedCardId);
+ } else {
+ const month = expiryDigits.slice(0, 2);
+ const year = expiryDigits.slice(2, 4);
+ fd.set("cardLast4", numberDigits.slice(-4));
+ fd.set("cardExpiryMonth", month);
+ fd.set("cardExpiryYear", `20${year}`);
+ fd.set("cardBrand", detectBrand(numberDigits));
+ fd.set("cardHolder", name.trim());
+ fd.set("saveCard", saveCard ? "true" : "false");
+ }
+
+ startConfirm(() => confirmMockPaymentAction(fd));
+ };
+
+ const handleCancel = () => {
+ const fd = new FormData();
+ fd.set("orderId", orderId);
+ startCancel(() => cancelMockPaymentAction(fd));
+ };
+
+ const busy = confirming || cancelling;
+
+ // Visual sync — saved card preview when in saved mode
+ const selected = savedCards.find((c) => c.$id === selectedCardId);
+ const visualNumber =
+ mode === "saved" && selected
+ ? `${"•".repeat(12)} ${selected.last4}`
+ : number;
+ const visualName =
+ mode === "saved" && selected ? selected.holderName ?? "" : name;
+ const visualExpiry =
+ mode === "saved" && selected
+ ? `${String(selected.expiryMonth).padStart(2, "0")}/${String(selected.expiryYear).slice(2)}`
+ : expiry;
+ const visualCvc = mode === "saved" ? "" : cvc;
+
+ return (
+
+
+
+
+
+
+
+ Test modu — gerçek kart bilgisi gerekmez. Onayladığında plan{" "}
+ {planPeriod} boyunca aktif
+ olur, tahsilat yapılmaz.
+
+
+
+
+
+
+
+ Ödenecek tutar
+
+
+ {trFmt.format(amount)}
+ {planName}
+
+
+
+ {savedCards.length > 0 && (
+
+ setMode("saved")}
+ disabled={busy}
+ >
+ Kayıtlı kart
+
+ setMode("new")}
+ disabled={busy}
+ >
+ Yeni kart
+
+
+ )}
+
+ {mode === "saved" && savedCards.length > 0 ? (
+
+ {savedCards.map((c) => (
+
+ setSelectedCardId(c.$id)}
+ disabled={busy}
+ />
+
+
+
+ {BRAND_LABEL[c.brand ?? "unknown"]} •••• {c.last4}
+
+
+ {c.holderName ?? "İsimsiz"} · Son kullanma{" "}
+ {String(c.expiryMonth).padStart(2, "0")}/{String(c.expiryYear).slice(2)}
+
+
+ {c.isDefault && (
+
+ Varsayılan
+
+ )}
+
+ ))}
+
+ ) : (
+
+
+ Kart numarası
+ setFlipped(false)}
+ onChange={(e) => setNumber(formatNumber(e.target.value))}
+ className="font-mono tracking-wider"
+ disabled={busy}
+ />
+
+
+
+ Kart üzerindeki ad
+ setFlipped(false)}
+ onChange={(e) => setName(e.target.value.toUpperCase())}
+ className="uppercase"
+ disabled={busy}
+ />
+
+
+
+
+
+ setSaveCard(Boolean(v))}
+ disabled={busy}
+ className="mt-0.5"
+ />
+
+
Bu kartı kaydet
+
+ Sonraki ödemelerde tek tıkla kullan. Kart numarasının yalnızca son 4 hanesi,
+ markası ve son kullanma tarihi saklanır — ham numara hiçbir yerde tutulmaz.
+
+
+
+
+ )}
+
+
+
+ {confirming ? (
+ <>
+
+ Onaylanıyor...
+ >
+ ) : (
+ <>
+
+ Güvenli ödeme — {trFmt.format(amount)}
+ >
+ )}
+
+
+ {cancelling ? (
+
+ ) : (
+
+ )}
+ Vazgeç
+
+
+
+ {!filled && mode === "new" && (
+
+ Onay butonu etkin olması için tüm kart alanlarını doldurman gerekir. Test modu —
+ herhangi bir 16 haneli numara çalışır.
+
+ )}
+
+
+
+ Plan & Faturalandırma'ya dön
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/settings/billing/checkout/[orderId]/page.tsx b/src/app/(dashboard)/settings/billing/checkout/[orderId]/page.tsx
new file mode 100644
index 0000000..e7ff199
--- /dev/null
+++ b/src/app/(dashboard)/settings/billing/checkout/[orderId]/page.tsx
@@ -0,0 +1,70 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { ArrowLeft } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import { listSavedCards } from "@/lib/appwrite/saved-card-queries";
+import { getPaymentByOrderId } from "@/lib/appwrite/subscription-queries";
+import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
+import { requireTenant } from "@/lib/appwrite/tenant-guard";
+
+import { MockPaymentForm } from "./components/mock-payment-form";
+
+export default async function MockCheckoutPage({
+ params,
+}: {
+ params: Promise<{ orderId: string }>;
+}) {
+ const { orderId } = await params;
+ const ctx = await requireTenant();
+ if (ctx.role !== "owner") redirect("/settings/billing");
+
+ const payment = await getPaymentByOrderId(ctx.tenantId, orderId);
+ if (!payment) redirect("/settings/billing");
+
+ if (payment.status === "success") redirect("/settings/billing?upgraded=1");
+ if (payment.status === "failed") redirect("/settings/billing?cancelled=1");
+
+ const plan = PLAN_CATALOG[payment.plan];
+ const savedCards = await listSavedCards(ctx.tenantId);
+
+ return (
+
+
+
+
+ Plan & Faturalandırma
+
+
+
Ödemeyi tamamla
+
+ Mock Test
+
+
+
+ Sipariş No: {payment.orderId} · Aşağıdaki kart
+ formunu doldur — alanlar gerçek zamanlı olarak karta yansır.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/settings/billing/page.tsx b/src/app/(dashboard)/settings/billing/page.tsx
index 98abaa4..9837d8b 100644
--- a/src/app/(dashboard)/settings/billing/page.tsx
+++ b/src/app/(dashboard)/settings/billing/page.tsx
@@ -1,51 +1,372 @@
-"use client"
+import Link from "next/link";
+import {
+ ArrowUpRight,
+ CheckCircle2,
+ CreditCard,
+ Crown,
+ Lock,
+ ShieldCheck,
+ Sparkles,
+ Star,
+ Trash2,
+} from "lucide-react";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
-import { PricingPlans } from "@/components/pricing-plans"
-import { CurrentPlanCard } from "./components/current-plan-card"
-import { BillingHistoryCard } from "./components/billing-history-card"
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { cn } from "@/lib/utils";
+import {
+ removeCardAction,
+ setDefaultCardAction,
+} from "@/lib/appwrite/saved-card-actions";
+import { listSavedCards } from "@/lib/appwrite/saved-card-queries";
+import type { CardBrand } from "@/lib/appwrite/schema";
+import { listPaymentsForTenant } from "@/lib/appwrite/subscription-queries";
+import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
+import { getEffectivePlan } from "@/lib/appwrite/plan-limits";
+import { requireTenant } from "@/lib/appwrite/tenant-guard";
-// Import data
-import currentPlanData from "./data/current-plan.json"
-import billingHistoryData from "./data/billing-history.json"
+const trFmt = new Intl.NumberFormat("tr-TR", {
+ style: "currency",
+ currency: "TRY",
+ maximumFractionDigits: 0,
+});
-export default function BillingSettings() {
- const handlePlanSelect = (planId: string) => {
- console.log('Plan selected:', planId)
- // Handle plan selection logic here
- }
+const dateFmt = new Intl.DateTimeFormat("tr-TR", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+});
+
+const STATUS_LABEL: Record = {
+ pending: "Bekliyor",
+ success: "Başarılı",
+ failed: "İptal",
+ refunded: "İade",
+};
+
+const STATUS_VARIANT: Record = {
+ pending: "secondary",
+ success: "default",
+ failed: "outline",
+ refunded: "destructive",
+};
+
+const PROVIDER_LABEL: Record = {
+ mock: "Mock (Test)",
+ shopier: "Shopier",
+};
+
+const BRAND_LABEL: Record = {
+ visa: "Visa",
+ mastercard: "Mastercard",
+ amex: "Amex",
+ troy: "troy",
+ unknown: "Kart",
+};
+
+export default async function BillingSettings({
+ searchParams,
+}: {
+ searchParams: Promise<{ upgraded?: string; cancelled?: string; downgraded?: string }>;
+}) {
+ const sp = await searchParams;
+ const ctx = await requireTenant();
+ const plan = getEffectivePlan(ctx);
+ const isPro = plan === "pro";
+ const canManage = ctx.role === "owner";
+
+ const [payments, savedCards] = await Promise.all([
+ listPaymentsForTenant(ctx.tenantId, 10),
+ listSavedCards(ctx.tenantId),
+ ]);
+
+ const expiresAt = ctx.settings?.planExpiresAt;
+ const catalog = isPro ? PLAN_CATALOG.pro : PLAN_CATALOG.free;
return (
-
-
-
Plans & Billing
-
- Manage your subscription and billing information.
-
-
-
-
-
-
-
-
-
-
-
- Available Plans
-
- Choose a plan that works best for you.
-
-
-
-
-
-
-
+
+
+
+ {ctx.settings?.companyName ?? "Çalışma alanı"}
+
+
Faturalandırma
+
+ Kayıtlı ödeme yöntemlerini, faturalarını ve veri saklama bilgilerini buradan yönet.
+
- )
+
+ {sp.upgraded && (
+
+
+
+ Pro plan aktif. Sınırsız kullanım açıldı.
+
+
+ )}
+ {sp.cancelled && (
+
+
+ Ödeme iptal edildi. Plan değişmedi.
+
+
+ )}
+ {sp.downgraded && (
+
+ Ücretsiz plana döndünüz.
+
+ )}
+
+
+
+
+
+
+ {isPro ? (
+
+ ) : (
+
+ )}
+ {catalog.name} plan
+
+
+ {isPro && expiresAt
+ ? `Yenileme tarihi: ${dateFmt.format(new Date(expiresAt))}`
+ : isPro
+ ? "Sınırsız kullanım."
+ : "Ücretsiz plan — sınırlar dolduğunda Pro'ya geç."}
+
+
+
+
+ Planları gör
+
+
+
+
+
+
+
+ {trFmt.format(catalog.price)}
+ /ay
+
+
+
+
+
+
+
+
+ Kayıtlı kartlar
+
+ Sonraki ödemelerde kullanılacak ödeme yöntemleri.
+
+
+ {canManage && !isPro && (
+
+
+ Pro'ya geç
+
+
+
+ )}
+
+
+
+ {savedCards.length === 0 ? (
+
+
+
Henüz kayıtlı kart yok.
+
+ Bir ödeme yaparken "Bu kartı kaydet" seçeneğini işaretle, kart bilgileri buraya
+ eklenir.
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ Kart bilgileriniz nasıl saklanır?
+
+
+
+
+
+
+
Ham kart numarası saklanmaz
+
+ İşletmem sunucularında kart numaranızın yalnızca son 4 hanesi, markası, son
+ kullanma tarihi ve kart sahibi adı tutulur. Tam kart numarası ve CVC asla
+ kaydedilmez.
+
+
+
+
+
+
+
Mock test modu
+
+ Şu an Pro plan mock ödeme akışıyla çalışır — gerçek tahsilat yapılmaz. Test
+ amaçlı girilen kart numaralarına ait yalnızca son 4 hane görüntü amacıyla
+ kaydedilir.
+
+
+
+
+
+
+
Shopier entegrasyonu sonrası
+
+ Shopier (Türkiye'de PCI-DSS uyumlu, BDDK lisanslı ödeme hizmeti sağlayıcısı)
+ devreye girdiğinde kart bilgileri Shopier'in altyapısında tokenize edilir.
+ İşletmem yalnızca tokeni saklar; bir sonraki ödemede tokenle Shopier'e tekrar
+ başvurulur. Token yalnızca o aboneliğe özeldir.
+
+
+
+
+ KVKK Aydınlatma Metni ve Mesafeli Satış Sözleşmesi yakında bu sayfaya eklenecek.
+
+
+
+
+
+
+ Ödeme geçmişi
+ Son 10 işlem.
+
+
+ {payments.length === 0 ? (
+
+ Henüz ödeme kaydı yok.
+
+ ) : (
+
+
+
+ Tarih
+ Sipariş No
+ Plan
+ Sağlayıcı
+ Durum
+ Tutar
+
+
+
+ {payments.map((p) => (
+
+
+ {dateFmt.format(new Date(p.$createdAt))}
+
+ {p.orderId}
+ {p.plan}
+ {PROVIDER_LABEL[p.provider ?? "mock"]}
+
+
+ {STATUS_LABEL[p.status ?? "pending"]}
+
+ {p.status === "pending" && (
+
+ Devam
+
+ )}
+
+
+ {trFmt.format(p.amount)}
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
}
diff --git a/src/app/(dashboard)/settings/members/components/invite-form.tsx b/src/app/(dashboard)/settings/members/components/invite-form.tsx
index 7892cd3..87fe579 100644
--- a/src/app/(dashboard)/settings/members/components/invite-form.tsx
+++ b/src/app/(dashboard)/settings/members/components/invite-form.tsx
@@ -14,19 +14,24 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
import { inviteMemberAction } from "@/lib/appwrite/team-actions";
import { initialInviteState } from "@/lib/appwrite/team-types";
export function InviteForm() {
const [state, formAction, isPending] = useActionState(inviteMemberAction, initialInviteState);
const [copied, setCopied] = useState(false);
+ const [planLimitOpen, setPlanLimitOpen] = useState(false);
const formRef = useRef
(null);
useEffect(() => {
if (state.ok && formRef.current) {
formRef.current.reset();
}
- }, [state.ok, state.shortUrl]);
+ if (state.code === "PLAN_LIMIT_EXCEEDED") {
+ setPlanLimitOpen(true);
+ }
+ }, [state.ok, state.shortUrl, state.code]);
const copy = async () => {
if (!state.shortUrl) return;
@@ -88,7 +93,7 @@ export function InviteForm() {
- {state.error && (
+ {state.error && state.code !== "PLAN_LIMIT_EXCEEDED" && (
{state.error}
@@ -109,6 +114,11 @@ export function InviteForm() {
)}
+
);
}
diff --git a/src/app/(dashboard)/settings/workspace/components/logo-uploader.tsx b/src/app/(dashboard)/settings/workspace/components/logo-uploader.tsx
new file mode 100644
index 0000000..b43b07f
--- /dev/null
+++ b/src/app/(dashboard)/settings/workspace/components/logo-uploader.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import { useActionState, useEffect, useRef, useState, useTransition } from "react";
+import { Building2, ImagePlus, Loader2, Trash2, Upload } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+import {
+ initialLogoState,
+ removeLogoAction,
+ uploadLogoAction,
+} from "@/lib/appwrite/logo-actions";
+
+type Props = {
+ canEdit: boolean;
+ currentLogoUrl: string | null;
+ companyName: string;
+};
+
+const MAX_BYTES = 2 * 1024 * 1024;
+const ALLOWED_MIME = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"];
+
+export function LogoUploader({ canEdit, currentLogoUrl, companyName }: Props) {
+ const [state, formAction, isPending] = useActionState(
+ uploadLogoAction,
+ initialLogoState,
+ );
+ const [removing, startRemove] = useTransition();
+ const [previewUrl, setPreviewUrl] = useState(currentLogoUrl);
+ const [dragOver, setDragOver] = useState(false);
+ const [selectedName, setSelectedName] = useState(null);
+ const formRef = useRef(null);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ setPreviewUrl(currentLogoUrl);
+ }, [currentLogoUrl]);
+
+ useEffect(() => {
+ if (state.ok) {
+ toast.success("Logo güncellendi.");
+ setSelectedName(null);
+ } else if (state.error) {
+ toast.error(state.error);
+ }
+ }, [state]);
+
+ const handleFile = (file: File | null) => {
+ if (!file) return;
+ if (!ALLOWED_MIME.includes(file.type)) {
+ toast.error("Sadece PNG, JPG, WebP veya SVG yükleyebilirsiniz.");
+ return;
+ }
+ if (file.size > MAX_BYTES) {
+ toast.error("Dosya 2MB'dan büyük olamaz.");
+ return;
+ }
+ setSelectedName(file.name);
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ setPreviewUrl(typeof e.target?.result === "string" ? e.target.result : null);
+ };
+ reader.readAsDataURL(file);
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ setDragOver(false);
+ const file = e.dataTransfer.files?.[0];
+ if (file && inputRef.current) {
+ const dt = new DataTransfer();
+ dt.items.add(file);
+ inputRef.current.files = dt.files;
+ handleFile(file);
+ }
+ };
+
+ const handleRemove = () => {
+ startRemove(async () => {
+ const result = await removeLogoAction();
+ if (result.ok) {
+ toast.success("Logo kaldırıldı.");
+ setPreviewUrl(null);
+ setSelectedName(null);
+ if (inputRef.current) inputRef.current.value = "";
+ } else {
+ toast.error(result.error ?? "Logo kaldırılamadı.");
+ }
+ });
+ };
+
+ const submitDisabled = isPending || removing || !selectedName;
+ const busy = isPending || removing;
+
+ return (
+
+
+
+
+ Logo
+
+
+ Faturalarda, panel başlığında ve dış paylaşımlarda görünür. PNG, JPG, WebP veya SVG —
+ en fazla 2 MB.
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/settings/workspace/page.tsx b/src/app/(dashboard)/settings/workspace/page.tsx
index 4567287..787afb4 100644
--- a/src/app/(dashboard)/settings/workspace/page.tsx
+++ b/src/app/(dashboard)/settings/workspace/page.tsx
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
+import { getLogoUrl } from "@/lib/appwrite/storage";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
+import { LogoUploader } from "./components/logo-uploader";
import { WorkspaceSettingsForm } from "./components/workspace-form";
export const metadata: Metadata = {
@@ -29,6 +31,12 @@ export default async function WorkspaceSettingsPage() {
+
+
-
+
{
if (state.ok) {
toast.success(isEdit ? "Yazılım güncellendi." : "Yazılım eklendi.");
onOpenChange(false);
+ } else if (state.code === "PLAN_LIMIT_EXCEEDED") {
+ setPlanLimitOpen(true);
} else if (state.error) {
toast.error(state.error);
}
@@ -108,7 +112,7 @@ export function SoftwareFormSheet({ open, onOpenChange, software }: Props) {
-
+
+
);
}
diff --git a/src/app/(dashboard)/tasks/components/task-form-sheet.tsx b/src/app/(dashboard)/tasks/components/task-form-sheet.tsx
index 5d4b376..7ec6159 100644
--- a/src/app/(dashboard)/tasks/components/task-form-sheet.tsx
+++ b/src/app/(dashboard)/tasks/components/task-form-sheet.tsx
@@ -190,7 +190,7 @@ export function TaskFormSheet({
-
+
-
-
-
+ {company.logoUrl ? (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ ) : (
+
+
+
+ )}
İşletmem
{company.name}
diff --git a/src/components/billing/plan-limit-dialog.tsx b/src/components/billing/plan-limit-dialog.tsx
new file mode 100644
index 0000000..fd18ff2
--- /dev/null
+++ b/src/components/billing/plan-limit-dialog.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import Link from "next/link";
+import { Crown } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+type Props = {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ message?: string;
+};
+
+export function PlanLimitDialog({ open, onOpenChange, message }: Props) {
+ return (
+
+
+
+
+
+ Ücretsiz plan sınırına ulaştınız
+
+
+ {message ?? "Yeni kayıt eklemek için Pro plana geçmeniz gerekiyor."}
+
+
+
+
Pro plan ile gelen avantajlar
+
+ Sınırsız müşteri, finans kaydı, yazılım
+ Sınırsız ekip üyesi
+ Audit log + öncelikli destek
+
+
+
+ onOpenChange(false)}>
+ Kapat
+
+
+
+
+ Pro'ya geç
+
+
+
+
+
+ );
+}
diff --git a/src/components/billing/usage-banner.tsx b/src/components/billing/usage-banner.tsx
new file mode 100644
index 0000000..ad7b875
--- /dev/null
+++ b/src/components/billing/usage-banner.tsx
@@ -0,0 +1,68 @@
+import Link from "next/link";
+import { AlertTriangle, Crown } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import {
+ RESOURCE_LABELS,
+ type PlanResource,
+ type PlanUsage,
+} from "@/lib/appwrite/plan-limits";
+
+const SOFT_THRESHOLD = 0.8;
+
+export function UsageBanner({
+ usage,
+ resource,
+}: {
+ usage: PlanUsage;
+ resource: PlanResource;
+}) {
+ if (usage.plan === "pro") return null;
+
+ const u = usage.usage[resource];
+ if (u.limit === Number.POSITIVE_INFINITY) return null;
+
+ const ratio = u.used / u.limit;
+ if (ratio < SOFT_THRESHOLD) return null;
+
+ const label = RESOURCE_LABELS[resource];
+ const reached = u.reached;
+
+ return (
+
+
+
+
+
+
+ {reached ? "Sınıra ulaşıldı" : "Sınıra yaklaşıyorsun"}:
+ {" "}
+
+ {u.used} / {u.limit} {label}.
+
+ {reached && (
+ Yeni {label} eklemek için Pro'ya geç.
+ )}
+
+
+
+
+
+ Pro'ya geç
+
+
+
+
+ );
+}
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
index 6906f5b..d5987a8 100644
--- a/src/components/ui/sheet.tsx
+++ b/src/components/ui/sheet.tsx
@@ -58,9 +58,9 @@ function SheetContent({
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
- "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
+ "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-dvh w-3/4 border-l sm:max-w-sm",
side === "left" &&
- "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
+ "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-dvh w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
@@ -93,7 +93,10 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
)
diff --git a/src/lib/appwrite/customer-actions.ts b/src/lib/appwrite/customer-actions.ts
index f009bcb..8169bc4 100644
--- a/src/lib/appwrite/customer-actions.ts
+++ b/src/lib/appwrite/customer-actions.ts
@@ -5,6 +5,11 @@ import { AppwriteException, ID, Permission, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
+import {
+ isPlanLimitError,
+ planLimitMessage,
+ requirePlanCapacity,
+} from "./plan-limits";
import { DATABASE_ID, TABLES, type Customer } from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
@@ -64,6 +69,19 @@ export async function createCustomerAction(
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
+ try {
+ await requirePlanCapacity(ctx, "customers");
+ } catch (e) {
+ if (isPlanLimitError(e)) {
+ return {
+ ok: false,
+ error: planLimitMessage(e.resource, e.limit),
+ code: "PLAN_LIMIT_EXCEEDED",
+ };
+ }
+ throw e;
+ }
+
try {
const { tablesDB } = createAdminClient();
const row = await tablesDB.createRow(
diff --git a/src/lib/appwrite/customer-types.ts b/src/lib/appwrite/customer-types.ts
index 5ce5189..bca98d7 100644
--- a/src/lib/appwrite/customer-types.ts
+++ b/src/lib/appwrite/customer-types.ts
@@ -2,6 +2,7 @@ export type CustomerActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record
;
+ code?: "PLAN_LIMIT_EXCEEDED";
};
export const initialCustomerState: CustomerActionState = { ok: false };
diff --git a/src/lib/appwrite/finance-actions.ts b/src/lib/appwrite/finance-actions.ts
index c2f66be..c2fbe3b 100644
--- a/src/lib/appwrite/finance-actions.ts
+++ b/src/lib/appwrite/finance-actions.ts
@@ -5,6 +5,11 @@ import { AppwriteException, ID } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
+import {
+ isPlanLimitError,
+ planLimitMessage,
+ requirePlanCapacity,
+} from "./plan-limits";
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
import { createAdminClient } from "./server";
@@ -68,6 +73,19 @@ export async function createFinanceEntryAction(
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
+ try {
+ await requirePlanCapacity(ctx, "financeEntries");
+ } catch (e) {
+ if (isPlanLimitError(e)) {
+ return {
+ ok: false,
+ error: planLimitMessage(e.resource, e.limit),
+ code: "PLAN_LIMIT_EXCEEDED",
+ };
+ }
+ throw e;
+ }
+
try {
const { tablesDB } = createAdminClient();
const data = { ...parsed.data, date: toIso(parsed.data.date) };
diff --git a/src/lib/appwrite/finance-types.ts b/src/lib/appwrite/finance-types.ts
index 94caafc..5a63312 100644
--- a/src/lib/appwrite/finance-types.ts
+++ b/src/lib/appwrite/finance-types.ts
@@ -2,6 +2,7 @@ export type FinanceActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record;
+ code?: "PLAN_LIMIT_EXCEEDED";
};
export const initialFinanceState: FinanceActionState = { ok: false };
diff --git a/src/lib/appwrite/logo-actions.ts b/src/lib/appwrite/logo-actions.ts
new file mode 100644
index 0000000..4e305a1
--- /dev/null
+++ b/src/lib/appwrite/logo-actions.ts
@@ -0,0 +1,177 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { ID, Permission, Role } from "node-appwrite";
+import { InputFile } from "node-appwrite/file";
+
+import { logAudit } from "./audit";
+import { BUCKETS, DATABASE_ID, TABLES } from "./schema";
+import { createAdminClient } from "./server";
+import { requireRole, requireTenant } from "./tenant-guard";
+
+const MAX_BYTES = 2 * 1024 * 1024;
+const ALLOWED_TYPES = new Set([
+ "image/png",
+ "image/jpeg",
+ "image/jpg",
+ "image/webp",
+ "image/svg+xml",
+]);
+
+export type LogoActionState = {
+ ok: boolean;
+ error?: string;
+};
+
+export const initialLogoState: LogoActionState = { ok: false };
+
+function teamLogoPermissions(tenantId: string) {
+ return [
+ Permission.read(Role.any()),
+ Permission.update(Role.team(tenantId, "owner")),
+ Permission.update(Role.team(tenantId, "admin")),
+ Permission.delete(Role.team(tenantId, "owner")),
+ Permission.delete(Role.team(tenantId, "admin")),
+ ];
+}
+
+export async function uploadLogoAction(
+ _prev: LogoActionState,
+ formData: FormData,
+): Promise {
+ let ctx;
+ try {
+ ctx = await requireTenant();
+ requireRole(ctx, ["owner", "admin"]);
+ } catch {
+ return { ok: false, error: "Logo yüklemek için yönetici yetkisi gerekli." };
+ }
+
+ const file = formData.get("logo");
+ if (!(file instanceof File) || file.size === 0) {
+ return { ok: false, error: "Dosya seçin." };
+ }
+
+ if (file.size > MAX_BYTES) {
+ return { ok: false, error: "Dosya 2MB'dan büyük olamaz." };
+ }
+ if (!ALLOWED_TYPES.has(file.type)) {
+ return { ok: false, error: "Sadece PNG, JPG, WebP veya SVG yükleyebilirsiniz." };
+ }
+ if (!ctx.settings) {
+ return { ok: false, error: "Çalışma alanı ayarları bulunamadı." };
+ }
+
+ const { storage, tablesDB } = createAdminClient();
+ const previousLogoId = ctx.settings.logo;
+
+ let newFileId: string | null = null;
+ try {
+ const buffer = Buffer.from(await file.arrayBuffer());
+ const inputFile = InputFile.fromBuffer(buffer, file.name);
+
+ const created = await storage.createFile({
+ bucketId: BUCKETS.tenantLogos,
+ fileId: ID.unique(),
+ file: inputFile,
+ permissions: teamLogoPermissions(ctx.tenantId),
+ });
+ newFileId = created.$id;
+
+ await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
+ logo: newFileId,
+ });
+
+ if (previousLogoId && previousLogoId !== newFileId) {
+ try {
+ await storage.deleteFile({
+ bucketId: BUCKETS.tenantLogos,
+ fileId: previousLogoId,
+ });
+ } catch {
+ // best-effort — orphaned file is acceptable, won't block the new logo
+ }
+ }
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "update",
+ entityType: "tenant_logo",
+ entityId: newFileId,
+ changes: { previous: previousLogoId ?? null },
+ });
+ } catch (e) {
+ if (newFileId) {
+ try {
+ await storage.deleteFile({
+ bucketId: BUCKETS.tenantLogos,
+ fileId: newFileId,
+ });
+ } catch {
+ /* ignore cleanup error */
+ }
+ }
+ return {
+ ok: false,
+ error: e instanceof Error ? e.message : "Logo yüklenemedi.",
+ };
+ }
+
+ revalidatePath("/settings/workspace");
+ revalidatePath("/", "layout");
+ return { ok: true };
+}
+
+export async function removeLogoAction(): Promise {
+ let ctx;
+ try {
+ ctx = await requireTenant();
+ requireRole(ctx, ["owner", "admin"]);
+ } catch {
+ return { ok: false, error: "Logo silmek için yönetici yetkisi gerekli." };
+ }
+
+ if (!ctx.settings) {
+ return { ok: false, error: "Çalışma alanı ayarları bulunamadı." };
+ }
+
+ const previousLogoId = ctx.settings.logo;
+ if (!previousLogoId) {
+ return { ok: true };
+ }
+
+ try {
+ const { storage, tablesDB } = createAdminClient();
+
+ await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
+ logo: null,
+ });
+
+ try {
+ await storage.deleteFile({
+ bucketId: BUCKETS.tenantLogos,
+ fileId: previousLogoId,
+ });
+ } catch {
+ /* file already gone, fine */
+ }
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "delete",
+ entityType: "tenant_logo",
+ entityId: previousLogoId,
+ });
+ } catch (e) {
+ return {
+ ok: false,
+ error: e instanceof Error ? e.message : "Logo silinemedi.",
+ };
+ }
+
+ revalidatePath("/settings/workspace");
+ revalidatePath("/", "layout");
+ return { ok: true };
+}
diff --git a/src/lib/appwrite/plan-limits.ts b/src/lib/appwrite/plan-limits.ts
new file mode 100644
index 0000000..9bb4ba8
--- /dev/null
+++ b/src/lib/appwrite/plan-limits.ts
@@ -0,0 +1,123 @@
+import "server-only";
+
+import { Query } from "node-appwrite";
+
+import { createAdminClient } from "./server";
+import { DATABASE_ID, TABLES, type TenantPlan } from "./schema";
+import type { TenantContext } from "./tenant-guard";
+
+export type PlanResource = "customers" | "financeEntries" | "software" | "members";
+
+export const PLAN_LIMIT_EXCEEDED = "PLAN_LIMIT_EXCEEDED";
+
+const INF = Number.POSITIVE_INFINITY;
+
+export const PLAN_LIMITS: Record> = {
+ free: {
+ customers: 50,
+ financeEntries: 100,
+ software: 5,
+ members: 1,
+ },
+ pro: {
+ customers: INF,
+ financeEntries: INF,
+ software: INF,
+ members: INF,
+ },
+};
+
+export const RESOURCE_LABELS: Record = {
+ customers: "müşteri",
+ financeEntries: "finans kaydı",
+ software: "yazılım",
+ members: "ekip üyesi",
+};
+
+export function getEffectivePlan(ctx: TenantContext): TenantPlan {
+ const plan = ctx.settings?.plan ?? "free";
+ if (plan === "pro") {
+ const expires = ctx.settings?.planExpiresAt;
+ if (expires && new Date(expires).getTime() < Date.now()) {
+ return "free";
+ }
+ }
+ return plan;
+}
+
+async function countResource(
+ tenantId: string,
+ resource: PlanResource,
+): Promise {
+ const { tablesDB, teams } = createAdminClient();
+
+ if (resource === "members") {
+ const result = await teams.listMemberships(tenantId);
+ return result.total;
+ }
+
+ const tableMap: Record, string> = {
+ customers: TABLES.customers,
+ financeEntries: TABLES.financeEntries,
+ software: TABLES.software,
+ };
+
+ const result = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: tableMap[resource],
+ queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
+ });
+ return result.total;
+}
+
+export type PlanUsage = {
+ plan: TenantPlan;
+ usage: Record;
+};
+
+export async function getPlanUsage(ctx: TenantContext): Promise {
+ const plan = getEffectivePlan(ctx);
+ const limits = PLAN_LIMITS[plan];
+
+ const resources: PlanResource[] = ["customers", "financeEntries", "software", "members"];
+ const counts = await Promise.all(resources.map((r) => countResource(ctx.tenantId, r)));
+
+ const usage = {} as PlanUsage["usage"];
+ resources.forEach((r, i) => {
+ const used = counts[i];
+ const limit = limits[r];
+ usage[r] = { used, limit, reached: used >= limit };
+ });
+
+ return { plan, usage };
+}
+
+export class PlanLimitError extends Error {
+ code = PLAN_LIMIT_EXCEEDED;
+ constructor(public resource: PlanResource, public limit: number) {
+ super(`Plan limit reached for ${resource} (${limit})`);
+ }
+}
+
+export async function requirePlanCapacity(
+ ctx: TenantContext,
+ resource: PlanResource,
+): Promise {
+ const plan = getEffectivePlan(ctx);
+ const limit = PLAN_LIMITS[plan][resource];
+ if (limit === INF) return;
+
+ const used = await countResource(ctx.tenantId, resource);
+ if (used >= limit) {
+ throw new PlanLimitError(resource, limit);
+ }
+}
+
+export function isPlanLimitError(e: unknown): e is PlanLimitError {
+ return e instanceof PlanLimitError;
+}
+
+export function planLimitMessage(resource: PlanResource, limit: number): string {
+ const label = RESOURCE_LABELS[resource];
+ return `Ücretsiz planda en fazla ${limit} ${label} ekleyebilirsiniz. Pro'ya geçerek sınırı kaldırın.`;
+}
diff --git a/src/lib/appwrite/saved-card-actions.ts b/src/lib/appwrite/saved-card-actions.ts
new file mode 100644
index 0000000..b365cae
--- /dev/null
+++ b/src/lib/appwrite/saved-card-actions.ts
@@ -0,0 +1,155 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { ID, Permission, Query, Role } from "node-appwrite";
+
+import { logAudit } from "./audit";
+import { DATABASE_ID, TABLES, type CardBrand, type SavedCard } from "./schema";
+import { createAdminClient } from "./server";
+import { requireRole, requireTenant } from "./tenant-guard";
+
+const VALID_BRANDS: CardBrand[] = ["visa", "mastercard", "amex", "troy", "unknown"];
+
+function teamCardPermissions(tenantId: string) {
+ return [
+ Permission.read(Role.team(tenantId, "owner")),
+ Permission.read(Role.team(tenantId, "admin")),
+ Permission.update(Role.team(tenantId, "owner")),
+ Permission.delete(Role.team(tenantId, "owner")),
+ ];
+}
+
+async function clearOtherDefaults(tenantId: string, exceptId?: string): Promise {
+ const { tablesDB } = createAdminClient();
+ const result = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.savedCards,
+ queries: [
+ Query.equal("tenantId", tenantId),
+ Query.equal("isDefault", true),
+ Query.limit(20),
+ ],
+ });
+ for (const row of result.rows) {
+ if (exceptId && row.$id === exceptId) continue;
+ await tablesDB.updateRow(DATABASE_ID, TABLES.savedCards, row.$id, {
+ isDefault: false,
+ });
+ }
+}
+
+type SaveCardInput = {
+ brand: string;
+ last4: string;
+ expiryMonth: number;
+ expiryYear: number;
+ holderName: string;
+ makeDefault: boolean;
+};
+
+export async function persistCardFromMockCheckout(input: SaveCardInput): Promise {
+ const ctx = await requireTenant();
+ requireRole(ctx, ["owner"]);
+
+ const brand = (VALID_BRANDS as string[]).includes(input.brand)
+ ? (input.brand as CardBrand)
+ : "unknown";
+ const last4 = input.last4.replace(/\D/g, "").slice(-4).padStart(4, "0");
+
+ if (last4.length !== 4) throw new Error("Geçersiz kart son 4 hanesi.");
+ if (input.expiryMonth < 1 || input.expiryMonth > 12) throw new Error("Geçersiz ay.");
+ if (input.expiryYear < 2026 || input.expiryYear > 2099) throw new Error("Geçersiz yıl.");
+
+ if (input.makeDefault) {
+ await clearOtherDefaults(ctx.tenantId);
+ }
+
+ const { tablesDB } = createAdminClient();
+ const row = await tablesDB.createRow(
+ DATABASE_ID,
+ TABLES.savedCards,
+ ID.unique(),
+ {
+ tenantId: ctx.tenantId,
+ createdBy: ctx.user.id,
+ brand,
+ last4,
+ expiryMonth: input.expiryMonth,
+ expiryYear: input.expiryYear,
+ holderName: input.holderName.trim().slice(0, 128) || undefined,
+ provider: "mock",
+ isDefault: input.makeDefault,
+ },
+ teamCardPermissions(ctx.tenantId),
+ );
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "create",
+ entityType: "saved_card",
+ entityId: row.$id,
+ changes: { brand, last4, isDefault: input.makeDefault },
+ });
+
+ revalidatePath("/settings/billing");
+}
+
+export async function setDefaultCardAction(formData: FormData): Promise {
+ const id = String(formData.get("id") ?? "");
+ if (!id) throw new Error("ID eksik.");
+
+ const ctx = await requireTenant();
+ requireRole(ctx, ["owner"]);
+
+ const { tablesDB } = createAdminClient();
+ const existing = (await tablesDB.getRow(
+ DATABASE_ID,
+ TABLES.savedCards,
+ id,
+ )) as unknown as SavedCard;
+ if (existing.tenantId !== ctx.tenantId) throw new Error("Erişim engellendi.");
+
+ await clearOtherDefaults(ctx.tenantId, id);
+ await tablesDB.updateRow(DATABASE_ID, TABLES.savedCards, id, { isDefault: true });
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "update",
+ entityType: "saved_card",
+ entityId: id,
+ changes: { isDefault: true },
+ });
+
+ revalidatePath("/settings/billing");
+}
+
+export async function removeCardAction(formData: FormData): Promise {
+ const id = String(formData.get("id") ?? "");
+ if (!id) throw new Error("ID eksik.");
+
+ const ctx = await requireTenant();
+ requireRole(ctx, ["owner"]);
+
+ const { tablesDB } = createAdminClient();
+ const existing = (await tablesDB.getRow(
+ DATABASE_ID,
+ TABLES.savedCards,
+ id,
+ )) as unknown as SavedCard;
+ if (existing.tenantId !== ctx.tenantId) throw new Error("Erişim engellendi.");
+
+ await tablesDB.deleteRow(DATABASE_ID, TABLES.savedCards, id);
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "delete",
+ entityType: "saved_card",
+ entityId: id,
+ changes: { last4: existing.last4 },
+ });
+
+ revalidatePath("/settings/billing");
+}
diff --git a/src/lib/appwrite/saved-card-queries.ts b/src/lib/appwrite/saved-card-queries.ts
new file mode 100644
index 0000000..50deb54
--- /dev/null
+++ b/src/lib/appwrite/saved-card-queries.ts
@@ -0,0 +1,35 @@
+import "server-only";
+
+import { Query } from "node-appwrite";
+
+import { DATABASE_ID, TABLES, type SavedCard } from "./schema";
+import { createAdminClient } from "./server";
+
+export async function listSavedCards(tenantId: string): Promise {
+ const { tablesDB } = createAdminClient();
+ const result = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.savedCards,
+ queries: [
+ Query.equal("tenantId", tenantId),
+ Query.orderDesc("isDefault"),
+ Query.orderDesc("$createdAt"),
+ Query.limit(20),
+ ],
+ });
+ return result.rows as unknown as SavedCard[];
+}
+
+export async function getDefaultCard(tenantId: string): Promise {
+ const { tablesDB } = createAdminClient();
+ const result = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.savedCards,
+ queries: [
+ Query.equal("tenantId", tenantId),
+ Query.equal("isDefault", true),
+ Query.limit(1),
+ ],
+ });
+ return (result.rows[0] as unknown as SavedCard) ?? null;
+}
diff --git a/src/lib/appwrite/schema.ts b/src/lib/appwrite/schema.ts
index f111525..7e2a89f 100644
--- a/src/lib/appwrite/schema.ts
+++ b/src/lib/appwrite/schema.ts
@@ -1,5 +1,9 @@
export const DATABASE_ID = "isletmem";
+export const BUCKETS = {
+ tenantLogos: "tenant-logos",
+} as const;
+
export const TABLES = {
tenantSettings: "tenant_settings",
customers: "customers",
@@ -18,6 +22,8 @@ export const TABLES = {
loanInstallments: "loan_installments",
creditCards: "credit_cards",
creditCardStatements: "credit_card_statements",
+ subscriptionPayments: "subscription_payments",
+ savedCards: "saved_cards",
} as const;
export type TableId = (typeof TABLES)[keyof typeof TABLES];
@@ -36,6 +42,8 @@ type Row = SystemRow;
export type TenantRole = "owner" | "admin" | "member";
+export type TenantPlan = "free" | "pro";
+
export interface TenantSettings extends Row {
tenantId: string;
companyName: string;
@@ -47,6 +55,10 @@ export interface TenantSettings extends Row {
defaultVatRate?: number;
invoicePrefix?: string;
invoiceCounter?: number;
+ plan?: TenantPlan;
+ planStartedAt?: string;
+ planExpiresAt?: string;
+ lastPaymentId?: string;
}
export type CustomerStatus = "active" | "passive";
@@ -263,6 +275,37 @@ export interface CreditCardStatement extends Row {
notes?: string;
}
+export type SubscriptionStatus = "pending" | "success" | "failed" | "refunded";
+export type SubscriptionProvider = "mock" | "shopier";
+
+export type CardBrand = "visa" | "mastercard" | "amex" | "troy" | "unknown";
+
+export interface SavedCard extends Row {
+ tenantId: string;
+ createdBy: string;
+ brand?: CardBrand;
+ last4: string;
+ expiryMonth: number;
+ expiryYear: number;
+ holderName?: string;
+ providerToken?: string;
+ provider?: SubscriptionProvider;
+ isDefault?: boolean;
+}
+
+export interface SubscriptionPayment extends Row {
+ tenantId: string;
+ createdBy: string;
+ orderId: string;
+ plan: TenantPlan;
+ amount: number;
+ currency?: string;
+ status?: SubscriptionStatus;
+ provider?: SubscriptionProvider;
+ providerPayload?: string;
+ processedAt?: string;
+}
+
export type InviteRole = "admin" | "member";
export type InviteStatus = "pending" | "accepted" | "cancelled" | "expired";
diff --git a/src/lib/appwrite/software-actions.ts b/src/lib/appwrite/software-actions.ts
index c9784e6..4da7aa8 100644
--- a/src/lib/appwrite/software-actions.ts
+++ b/src/lib/appwrite/software-actions.ts
@@ -5,6 +5,11 @@ import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
+import {
+ isPlanLimitError,
+ planLimitMessage,
+ requirePlanCapacity,
+} from "./plan-limits";
import {
DATABASE_ID,
TABLES,
@@ -68,6 +73,19 @@ export async function createSoftwareAction(
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
+ try {
+ await requirePlanCapacity(ctx, "software");
+ } catch (e) {
+ if (isPlanLimitError(e)) {
+ return {
+ ok: false,
+ error: planLimitMessage(e.resource, e.limit),
+ code: "PLAN_LIMIT_EXCEEDED",
+ };
+ }
+ throw e;
+ }
+
try {
const { tablesDB } = createAdminClient();
const row = await tablesDB.createRow(
diff --git a/src/lib/appwrite/software-types.ts b/src/lib/appwrite/software-types.ts
index 500c88c..394b296 100644
--- a/src/lib/appwrite/software-types.ts
+++ b/src/lib/appwrite/software-types.ts
@@ -2,6 +2,7 @@ export type SoftwareActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record;
+ code?: "PLAN_LIMIT_EXCEEDED";
};
export const initialSoftwareState: SoftwareActionState = { ok: false };
diff --git a/src/lib/appwrite/storage.ts b/src/lib/appwrite/storage.ts
new file mode 100644
index 0000000..3f51456
--- /dev/null
+++ b/src/lib/appwrite/storage.ts
@@ -0,0 +1,16 @@
+import "server-only";
+
+import { BUCKETS } from "./schema";
+
+const ENDPOINT = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT?.replace(/\/$/, "") ?? "";
+const PROJECT_ID = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID ?? "";
+
+export function getFileViewUrl(bucketId: string, fileId: string): string {
+ if (!ENDPOINT || !PROJECT_ID || !fileId) return "";
+ return `${ENDPOINT}/storage/buckets/${bucketId}/files/${fileId}/view?project=${PROJECT_ID}`;
+}
+
+export function getLogoUrl(fileId?: string | null): string | null {
+ if (!fileId) return null;
+ return getFileViewUrl(BUCKETS.tenantLogos, fileId);
+}
diff --git a/src/lib/appwrite/subscription-actions.ts b/src/lib/appwrite/subscription-actions.ts
new file mode 100644
index 0000000..049c98f
--- /dev/null
+++ b/src/lib/appwrite/subscription-actions.ts
@@ -0,0 +1,220 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { ID, Permission, Query, Role } from "node-appwrite";
+
+import { logAudit } from "./audit";
+import { persistCardFromMockCheckout } from "./saved-card-actions";
+import { getDefaultCard } from "./saved-card-queries";
+import { DATABASE_ID, TABLES, type SubscriptionPayment, type TenantPlan } from "./schema";
+import { createAdminClient } from "./server";
+import { requireRole, requireTenant } from "./tenant-guard";
+import { PLAN_CATALOG } from "./subscription-types";
+
+const PRO_VALIDITY_DAYS = 30;
+
+function teamRowPermissions(tenantId: string) {
+ return [
+ Permission.read(Role.team(tenantId, "owner")),
+ Permission.read(Role.team(tenantId, "admin")),
+ Permission.update(Role.team(tenantId, "owner")),
+ Permission.delete(Role.team(tenantId, "owner")),
+ ];
+}
+
+function generateOrderId(): string {
+ const t = Date.now().toString(36);
+ const r = Math.random().toString(36).slice(2, 10);
+ return `ord_${t}_${r}`;
+}
+
+export async function startMockCheckoutAction(formData: FormData): Promise {
+ const plan = String(formData.get("plan") ?? "") as TenantPlan;
+ if (plan !== "pro") throw new Error("Geçersiz plan.");
+
+ const ctx = await requireTenant();
+ requireRole(ctx, ["owner"]);
+
+ const catalog = PLAN_CATALOG[plan];
+ const orderId = generateOrderId();
+
+ const { tablesDB } = createAdminClient();
+ await tablesDB.createRow(
+ DATABASE_ID,
+ TABLES.subscriptionPayments,
+ ID.unique(),
+ {
+ tenantId: ctx.tenantId,
+ createdBy: ctx.user.id,
+ orderId,
+ plan,
+ amount: catalog.price,
+ currency: catalog.currency,
+ status: "pending",
+ provider: "mock",
+ },
+ teamRowPermissions(ctx.tenantId),
+ );
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "create",
+ entityType: "subscription_payment",
+ entityId: orderId,
+ changes: { plan, amount: catalog.price, provider: "mock" },
+ });
+
+ redirect(`/settings/billing/checkout/${orderId}`);
+}
+
+async function findPendingPaymentByOrderId(
+ tenantId: string,
+ orderId: string,
+): Promise {
+ const { tablesDB } = createAdminClient();
+ const result = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.subscriptionPayments,
+ queries: [Query.equal("orderId", orderId), Query.equal("tenantId", tenantId), Query.limit(1)],
+ });
+ return (result.rows[0] as unknown as SubscriptionPayment) ?? null;
+}
+
+export async function confirmMockPaymentAction(formData: FormData): Promise {
+ const orderId = String(formData.get("orderId") ?? "");
+ if (!orderId) throw new Error("orderId eksik.");
+
+ const ctx = await requireTenant();
+ requireRole(ctx, ["owner"]);
+
+ const payment = await findPendingPaymentByOrderId(ctx.tenantId, orderId);
+ if (!payment) throw new Error("Ödeme bulunamadı.");
+ if (payment.status === "success") {
+ redirect(`/settings/billing?upgraded=1`);
+ }
+ if (payment.provider !== "mock") {
+ throw new Error("Bu ödeme mock olarak onaylanamaz.");
+ }
+
+ const saveCard = String(formData.get("saveCard") ?? "") === "true";
+ const useSavedCardId = String(formData.get("savedCardId") ?? "");
+
+ const { tablesDB } = createAdminClient();
+ const now = new Date();
+ const expires = new Date(now.getTime() + PRO_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
+
+ await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
+ status: "success",
+ processedAt: now.toISOString(),
+ providerPayload: JSON.stringify({
+ mock: true,
+ confirmedBy: ctx.user.id,
+ usedSavedCardId: useSavedCardId || undefined,
+ }),
+ });
+
+ if (ctx.settings) {
+ await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
+ plan: payment.plan,
+ planStartedAt: now.toISOString(),
+ planExpiresAt: expires.toISOString(),
+ lastPaymentId: payment.$id,
+ });
+ }
+
+ if (saveCard && !useSavedCardId) {
+ const last4 = String(formData.get("cardLast4") ?? "").replace(/\D/g, "").slice(-4);
+ const month = parseInt(String(formData.get("cardExpiryMonth") ?? "0"), 10);
+ const year = parseInt(String(formData.get("cardExpiryYear") ?? "0"), 10);
+ const brand = String(formData.get("cardBrand") ?? "unknown");
+ const holder = String(formData.get("cardHolder") ?? "").trim();
+
+ if (last4.length === 4 && month > 0 && year >= 2026) {
+ const existingDefault = await getDefaultCard(ctx.tenantId);
+ try {
+ await persistCardFromMockCheckout({
+ brand,
+ last4,
+ expiryMonth: month,
+ expiryYear: year,
+ holderName: holder,
+ makeDefault: !existingDefault,
+ });
+ } catch {
+ // best-effort — payment already succeeded, don't fail upgrade if card save errors
+ }
+ }
+ }
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "update",
+ entityType: "subscription_payment",
+ entityId: payment.$id,
+ changes: { status: "success", plan: payment.plan, expires: expires.toISOString() },
+ });
+
+ revalidatePath("/settings/billing");
+ redirect(`/settings/billing?upgraded=1`);
+}
+
+export async function cancelMockPaymentAction(formData: FormData): Promise {
+ const orderId = String(formData.get("orderId") ?? "");
+ if (!orderId) throw new Error("orderId eksik.");
+
+ const ctx = await requireTenant();
+ requireRole(ctx, ["owner"]);
+
+ const payment = await findPendingPaymentByOrderId(ctx.tenantId, orderId);
+ if (!payment) {
+ redirect(`/settings/billing`);
+ }
+ if (payment && payment.status === "pending") {
+ const { tablesDB } = createAdminClient();
+ await tablesDB.updateRow(DATABASE_ID, TABLES.subscriptionPayments, payment.$id, {
+ status: "failed",
+ processedAt: new Date().toISOString(),
+ providerPayload: JSON.stringify({ mock: true, cancelledBy: ctx.user.id }),
+ });
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "update",
+ entityType: "subscription_payment",
+ entityId: payment.$id,
+ changes: { status: "failed" },
+ });
+ }
+
+ revalidatePath("/settings/billing");
+ redirect(`/settings/billing?cancelled=1`);
+}
+
+export async function downgradeToFreeAction(): Promise {
+ const ctx = await requireTenant();
+ requireRole(ctx, ["owner"]);
+
+ if (!ctx.settings) throw new Error("Ayar yok.");
+
+ const { tablesDB } = createAdminClient();
+ await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, ctx.settings.$id, {
+ plan: "free",
+ planExpiresAt: null,
+ });
+
+ await logAudit({
+ tenantId: ctx.tenantId,
+ userId: ctx.user.id,
+ action: "update",
+ entityType: "tenant_settings",
+ entityId: ctx.settings.$id,
+ changes: { plan: "free" },
+ });
+
+ revalidatePath("/settings/billing");
+ redirect(`/settings/billing?downgraded=1`);
+}
diff --git a/src/lib/appwrite/subscription-queries.ts b/src/lib/appwrite/subscription-queries.ts
new file mode 100644
index 0000000..753b086
--- /dev/null
+++ b/src/lib/appwrite/subscription-queries.ts
@@ -0,0 +1,40 @@
+import "server-only";
+
+import { Query } from "node-appwrite";
+
+import { DATABASE_ID, TABLES, type SubscriptionPayment } from "./schema";
+import { createAdminClient } from "./server";
+
+export async function getPaymentByOrderId(
+ tenantId: string,
+ orderId: string,
+): Promise {
+ const { tablesDB } = createAdminClient();
+ const result = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.subscriptionPayments,
+ queries: [
+ Query.equal("orderId", orderId),
+ Query.equal("tenantId", tenantId),
+ Query.limit(1),
+ ],
+ });
+ return (result.rows[0] as unknown as SubscriptionPayment) ?? null;
+}
+
+export async function listPaymentsForTenant(
+ tenantId: string,
+ limit = 20,
+): Promise {
+ const { tablesDB } = createAdminClient();
+ const result = await tablesDB.listRows({
+ databaseId: DATABASE_ID,
+ tableId: TABLES.subscriptionPayments,
+ queries: [
+ Query.equal("tenantId", tenantId),
+ Query.orderDesc("$createdAt"),
+ Query.limit(limit),
+ ],
+ });
+ return result.rows as unknown as SubscriptionPayment[];
+}
diff --git a/src/lib/appwrite/subscription-types.ts b/src/lib/appwrite/subscription-types.ts
new file mode 100644
index 0000000..6f775b3
--- /dev/null
+++ b/src/lib/appwrite/subscription-types.ts
@@ -0,0 +1,40 @@
+import type { TenantPlan } from "./schema";
+
+export type PlanCatalogEntry = {
+ id: TenantPlan;
+ name: string;
+ price: number;
+ currency: string;
+ description: string;
+ features: string[];
+};
+
+export const PLAN_CATALOG: Record = {
+ free: {
+ id: "free",
+ name: "Ücretsiz",
+ price: 0,
+ currency: "TRY",
+ description: "Tek kullanıcı, denemek için.",
+ features: [
+ "50 müşteri",
+ "100 finans kaydı",
+ "5 yazılım",
+ "Tek kullanıcı",
+ ],
+ },
+ pro: {
+ id: "pro",
+ name: "Pro",
+ price: 299,
+ currency: "TRY",
+ description: "Sınırsız büyüyen ekipler için.",
+ features: [
+ "Sınırsız müşteri",
+ "Sınırsız finans kaydı",
+ "Sınırsız yazılım",
+ "Sınırsız ekip üyesi",
+ "Audit log + öncelikli destek",
+ ],
+ },
+};
diff --git a/src/lib/appwrite/team-actions.ts b/src/lib/appwrite/team-actions.ts
index 7c75e68..76f1b19 100644
--- a/src/lib/appwrite/team-actions.ts
+++ b/src/lib/appwrite/team-actions.ts
@@ -5,6 +5,11 @@ import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { logAudit } from "./audit";
+import {
+ isPlanLimitError,
+ planLimitMessage,
+ requirePlanCapacity,
+} from "./plan-limits";
import { DATABASE_ID, TABLES, type InviteLink, type InviteRole } from "./schema";
import { createAdminClient, createSessionClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
@@ -66,6 +71,19 @@ export async function inviteMemberAction(
return { ok: false, error: "Kendinizi davet edemezsiniz." };
}
+ try {
+ await requirePlanCapacity(ctx, "members");
+ } catch (e) {
+ if (isPlanLimitError(e)) {
+ return {
+ ok: false,
+ error: planLimitMessage(e.resource, e.limit),
+ code: "PLAN_LIMIT_EXCEEDED",
+ };
+ }
+ throw e;
+ }
+
const admin = createAdminClient();
// 1. Kullanıcı zaten Appwrite'ta var mı?
diff --git a/src/lib/appwrite/team-types.ts b/src/lib/appwrite/team-types.ts
index 9aa5d58..47f6f78 100644
--- a/src/lib/appwrite/team-types.ts
+++ b/src/lib/appwrite/team-types.ts
@@ -3,6 +3,7 @@ export type InviteState = {
error?: string;
shortUrl?: string;
message?: string;
+ code?: "PLAN_LIMIT_EXCEEDED";
};
export const initialInviteState: InviteState = { ok: false };
diff --git a/src/lib/appwrite/tenant-actions.ts b/src/lib/appwrite/tenant-actions.ts
index fdf5630..d6f7084 100644
--- a/src/lib/appwrite/tenant-actions.ts
+++ b/src/lib/appwrite/tenant-actions.ts
@@ -58,6 +58,8 @@ export async function createWorkspaceAction(
companyName,
companyTaxId,
companyPhone,
+ plan: "free",
+ planStartedAt: new Date().toISOString(),
},
[
Permission.read(Role.team(teamId)),