From 196036c0d837deafc74d110eeb5a7ddc7bf2e86b Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 30 Apr 2026 21:36:01 +0300 Subject: [PATCH] feat: plan tier system, mock checkout, saved cards, tenant logo upload + mobile sheet fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan & billing layer: - New tables: subscription_payments, saved_cards (via Appwrite MCP) - tenant_settings: plan/planStartedAt/planExpiresAt/lastPaymentId columns - Free tier limits (50 customers / 100 finance entries / 5 software / 1 member) enforced via requirePlanCapacity gate in create actions - PlanLimitDialog opens when limit hit; UsageBanner at 80% threshold - /pricing rebuilt with Free + Pro tiers and Klinik/Ajans ecosystem teasers - /settings/billing redesigned: compact plan summary, saved cards list, KVKK transparency block, payment history - Usage stats moved to /pricing where they are decision-relevant Mock checkout flow: - 3D animated credit card with sync inputs and CVC flip - Brand auto-detection (Visa / Mastercard / Amex / troy) - Saved-card mode when previous cards exist; first card defaults to default - 'Bu kartı kaydet' checkbox with explicit storage scope disclosure - /settings/billing/checkout/[orderId] route Saved cards: - saved_cards bucket stores last4 + brand + expiry + holder only - Default toggle, remove action, owner-only management - Architecture ready for Shopier provider token swap-in Tenant logo upload (first file upload feature): - New Appwrite bucket: tenant-logos (max 2MB, image only, public read) - uploadLogoAction with orphan cleanup, removeLogoAction - LogoUploader UI: drag-drop, client-side preview, validation - Sidebar shows logo when set, falls back to default icon Mobile sheet fix: - SheetContent uses h-dvh instead of h-full (dynamic viewport) - SheetFooter pads pb-[max(1rem,env(safe-area-inset-bottom))] - 13 form sheets switched py-4 → pt-4 to let safe-area apply db: subscription_payments, saved_cards tables; tenant_settings plan columns; tenant-logos storage bucket --- .../calendar/components/event-form-sheet.tsx | 2 +- .../components/customer-form-sheet.tsx | 13 +- src/app/(dashboard)/customers/page.tsx | 9 +- src/app/(dashboard)/dashboard-shell.tsx | 1 + .../banks/components/bank-form-sheet.tsx | 2 +- .../cards/components/card-form-sheet.tsx | 2 +- .../cards/components/statement-form-sheet.tsx | 2 +- .../finance/components/finance-form-sheet.tsx | 14 +- .../loans/components/loan-form-sheet.tsx | 2 +- .../invoices/[id]/components/items-editor.tsx | 2 +- .../components/invoice-form-sheet.tsx | 2 +- src/app/(dashboard)/layout.tsx | 2 + src/app/(dashboard)/pricing/page.tsx | 300 ++++++++++++- .../components/service-form-sheet.tsx | 2 +- .../components/credit-card-visual.tsx | 151 +++++++ .../components/mock-payment-form.tsx | 379 ++++++++++++++++ .../billing/checkout/[orderId]/page.tsx | 70 +++ src/app/(dashboard)/settings/billing/page.tsx | 409 ++++++++++++++++-- .../members/components/invite-form.tsx | 14 +- .../workspace/components/logo-uploader.tsx | 211 +++++++++ .../(dashboard)/settings/workspace/page.tsx | 8 + .../components/assignment-form-sheet.tsx | 2 +- .../components/software-form-sheet.tsx | 13 +- .../tasks/components/task-form-sheet.tsx | 2 +- src/components/app-sidebar.tsx | 17 +- src/components/billing/plan-limit-dialog.tsx | 57 +++ src/components/billing/usage-banner.tsx | 68 +++ src/components/ui/sheet.tsx | 9 +- src/lib/appwrite/customer-actions.ts | 18 + src/lib/appwrite/customer-types.ts | 1 + src/lib/appwrite/finance-actions.ts | 18 + src/lib/appwrite/finance-types.ts | 1 + src/lib/appwrite/logo-actions.ts | 177 ++++++++ src/lib/appwrite/plan-limits.ts | 123 ++++++ src/lib/appwrite/saved-card-actions.ts | 155 +++++++ src/lib/appwrite/saved-card-queries.ts | 35 ++ src/lib/appwrite/schema.ts | 43 ++ src/lib/appwrite/software-actions.ts | 18 + src/lib/appwrite/software-types.ts | 1 + src/lib/appwrite/storage.ts | 16 + src/lib/appwrite/subscription-actions.ts | 220 ++++++++++ src/lib/appwrite/subscription-queries.ts | 40 ++ src/lib/appwrite/subscription-types.ts | 40 ++ src/lib/appwrite/team-actions.ts | 18 + src/lib/appwrite/team-types.ts | 1 + src/lib/appwrite/tenant-actions.ts | 2 + 46 files changed, 2607 insertions(+), 85 deletions(-) create mode 100644 src/app/(dashboard)/settings/billing/checkout/[orderId]/components/credit-card-visual.tsx create mode 100644 src/app/(dashboard)/settings/billing/checkout/[orderId]/components/mock-payment-form.tsx create mode 100644 src/app/(dashboard)/settings/billing/checkout/[orderId]/page.tsx create mode 100644 src/app/(dashboard)/settings/workspace/components/logo-uploader.tsx create mode 100644 src/components/billing/plan-limit-dialog.tsx create mode 100644 src/components/billing/usage-banner.tsx create mode 100644 src/lib/appwrite/logo-actions.ts create mode 100644 src/lib/appwrite/plan-limits.ts create mode 100644 src/lib/appwrite/saved-card-actions.ts create mode 100644 src/lib/appwrite/saved-card-queries.ts create mode 100644 src/lib/appwrite/storage.ts create mode 100644 src/lib/appwrite/subscription-actions.ts create mode 100644 src/lib/appwrite/subscription-queries.ts create mode 100644 src/lib/appwrite/subscription-types.ts 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) {
- +
- +
- +
- +
- +
{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({
- +
- +
- +
+ ) : !canManage ? ( + + ) : 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) => ( + + +
+
+ +
+ + Yakında + +
+ {t.name} +

{t.description}

+
+ +
+ {t.features.map((feature) => ( +
+
+ +
+ {feature} +
+ ))} +
+
+ + + +
+ ))} +
+
+ + + +

+ 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({ - +
+ +
+ )} + + {mode === "saved" && savedCards.length > 0 ? ( +
+ {savedCards.map((c) => ( + + ))} +
+ ) : ( +
+
+ + setFlipped(false)} + onChange={(e) => setNumber(formatNumber(e.target.value))} + className="font-mono tracking-wider" + disabled={busy} + /> +
+ +
+ + setFlipped(false)} + onChange={(e) => setName(e.target.value.toUpperCase())} + className="uppercase" + disabled={busy} + /> +
+ +
+
+ + setFlipped(false)} + onChange={(e) => setExpiry(formatExpiry(e.target.value))} + className="font-mono" + disabled={busy} + /> +
+
+ + setFlipped(true)} + onBlur={() => setFlipped(false)} + onChange={(e) => setCvc(formatCvc(e.target.value))} + className="font-mono" + disabled={busy} + /> +
+
+ + +
+ )} + +
+ + +
+ + {!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ç."} + +
+ +
+
+ +
+ {trFmt.format(catalog.price)} + /ay +
+
+
+ + + +
+
+ Kayıtlı kartlar + + Sonraki ödemelerde kullanılacak ödeme yöntemleri. + +
+ {canManage && !isPro && ( + + )} +
+
+ + {savedCards.length === 0 ? ( +
+ +

Henüz kayıtlı kart yok.

+

+ Bir ödeme yaparken "Bu kartı kaydet" seçeneğini işaretle, kart bilgileri buraya + eklenir. +

+
+ ) : ( +
    + {savedCards.map((c) => ( +
  • +
    + +
    +
    +
    + {BRAND_LABEL[c.brand ?? "unknown"]} •••• {c.last4} + {c.isDefault && ( + + + Varsayılan + + )} + {c.provider === "mock" && ( + + Mock + + )} +
    +
    + {c.holderName ?? "İsimsiz"} · Son kullanma{" "} + {String(c.expiryMonth).padStart(2, "0")}/{String(c.expiryYear).slice(2)} +
    +
    + {canManage && ( +
    + {!c.isDefault && ( +
    + + +
    + )} +
    + + +
    +
    + )} +
  • + ))} +
+ )} +
+
+ + + + + + 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. + + + +
+
+
+ {previewUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${companyName} + ) : ( +
+ + Henüz logo yok +
+ )} +
+ +
+ + +
+ {canEdit && ( + + )} + {canEdit && currentLogoUrl && ( + + )} + {!canEdit && ( +

+ Logo değiştirmek için yönetici yetkisi gerekli. +

+ )} +
+
+
+
+
+
+ ); +} 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() {

+ + - +
- +
- +
+ + + + + ); +} 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ç. + )} +
+
+ +
+
+ ); +} 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)),