feat: plan tier system, mock checkout, saved cards, tenant logo upload + mobile sheet fix

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
This commit is contained in:
kovakmedya
2026-04-30 21:36:01 +03:00
parent ab336b191f
commit 196036c0d8
46 changed files with 2607 additions and 85 deletions
@@ -225,7 +225,7 @@ export function EventFormSheet({
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full items-center justify-between gap-2"> <div className="flex w-full items-center justify-between gap-2">
<div> <div>
{isEdit && event && onRequestDelete && ( {isEdit && event && onRequestDelete && (
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useActionState, useEffect } from "react"; import { useActionState, useEffect, useState } from "react";
import { Loader2, Save } from "lucide-react"; import { Loader2, Save } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -23,6 +23,7 @@ import {
SheetTitle, SheetTitle,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
import { import {
createCustomerAction, createCustomerAction,
updateCustomerAction, updateCustomerAction,
@@ -40,11 +41,14 @@ export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
const isEdit = Boolean(customer); const isEdit = Boolean(customer);
const action = isEdit ? updateCustomerAction : createCustomerAction; const action = isEdit ? updateCustomerAction : createCustomerAction;
const [state, formAction, isPending] = useActionState(action, initialCustomerState); const [state, formAction, isPending] = useActionState(action, initialCustomerState);
const [planLimitOpen, setPlanLimitOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (state.ok) { if (state.ok) {
toast.success(isEdit ? "Müşteri güncellendi." : "Müşteri eklendi."); toast.success(isEdit ? "Müşteri güncellendi." : "Müşteri eklendi.");
onOpenChange(false); onOpenChange(false);
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
setPlanLimitOpen(true);
} else if (state.error) { } else if (state.error) {
toast.error(state.error); toast.error(state.error);
} }
@@ -155,7 +159,7 @@ export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2"> <div className="flex w-full justify-end gap-2">
<Button <Button
type="button" type="button"
@@ -182,6 +186,11 @@ export function CustomerFormSheet({ open, onOpenChange, customer }: Props) {
</SheetFooter> </SheetFooter>
</form> </form>
</SheetContent> </SheetContent>
<PlanLimitDialog
open={planLimitOpen}
onOpenChange={setPlanLimitOpen}
message={state.error}
/>
</Sheet> </Sheet>
); );
} }
+8 -1
View File
@@ -1,7 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { UsageBanner } from "@/components/billing/usage-banner";
import { listCustomers } from "@/lib/appwrite/customer-queries"; import { listCustomers } from "@/lib/appwrite/customer-queries";
import { getPlanUsage } from "@/lib/appwrite/plan-limits";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { CustomersClient } from "./components/customers-client"; import { CustomersClient } from "./components/customers-client";
@@ -17,7 +19,10 @@ export default async function CustomersPage() {
redirect("/onboarding"); redirect("/onboarding");
} }
const customers = await listCustomers(ctx.tenantId); const [customers, usage] = await Promise.all([
listCustomers(ctx.tenantId),
getPlanUsage(ctx),
]);
return ( return (
<div className="flex-1 space-y-6 px-6 pt-0"> <div className="flex-1 space-y-6 px-6 pt-0">
@@ -29,6 +34,8 @@ export default async function CustomersPage() {
</p> </p>
</div> </div>
<UsageBanner usage={usage} resource="customers" />
<CustomersClient <CustomersClient
customers={customers.map((c) => ({ customers={customers.map((c) => ({
id: c.$id, id: c.$id,
+1
View File
@@ -18,6 +18,7 @@ export type ShellUser = {
export type ShellCompany = { export type ShellCompany = {
id: string; id: string;
name: string; name: string;
logoUrl?: string | null;
}; };
export function DashboardShell({ export function DashboardShell({
@@ -130,7 +130,7 @@ export function BankFormSheet({ open, onOpenChange, account }: Props) {
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2"> <div className="flex w-full justify-end gap-2">
<Button <Button
type="button" type="button"
@@ -201,7 +201,7 @@ export function CardFormSheet({
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2"> <div className="flex w-full justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç Vazgeç
@@ -170,7 +170,7 @@ export function StatementFormSheet({
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2"> <div className="flex w-full justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç Vazgeç
@@ -1,9 +1,11 @@
"use client"; "use client";
import { useActionState, useEffect } from "react"; import { useActionState, useEffect, useState } from "react";
import { Loader2, Save, Trash2 } from "lucide-react"; import { Loader2, Save, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -61,11 +63,14 @@ export function FinanceFormSheet({
const isEdit = Boolean(entry); const isEdit = Boolean(entry);
const action = isEdit ? updateFinanceEntryAction : createFinanceEntryAction; const action = isEdit ? updateFinanceEntryAction : createFinanceEntryAction;
const [state, formAction, isPending] = useActionState(action, initialFinanceState); const [state, formAction, isPending] = useActionState(action, initialFinanceState);
const [planLimitOpen, setPlanLimitOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (state.ok) { if (state.ok) {
toast.success(isEdit ? "Kayıt güncellendi." : "Kayıt eklendi."); toast.success(isEdit ? "Kayıt güncellendi." : "Kayıt eklendi.");
onOpenChange(false); onOpenChange(false);
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
setPlanLimitOpen(true);
} else if (state.error) { } else if (state.error) {
toast.error(state.error); toast.error(state.error);
} }
@@ -216,7 +221,7 @@ export function FinanceFormSheet({
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full items-center justify-between gap-2"> <div className="flex w-full items-center justify-between gap-2">
<div> <div>
{isEdit && entry && onRequestDelete && ( {isEdit && entry && onRequestDelete && (
@@ -260,6 +265,11 @@ export function FinanceFormSheet({
</SheetFooter> </SheetFooter>
</form> </form>
</SheetContent> </SheetContent>
<PlanLimitDialog
open={planLimitOpen}
onOpenChange={setPlanLimitOpen}
message={state.error}
/>
</Sheet> </Sheet>
); );
} }
@@ -230,7 +230,7 @@ export function LoanFormSheet({
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2"> <div className="flex w-full justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç Vazgeç
@@ -271,7 +271,7 @@ function ItemFormSheet({
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2"> <div className="flex w-full justify-end gap-2">
<Button <Button
type="button" type="button"
@@ -162,7 +162,7 @@ export function InvoiceFormSheet({ open, onOpenChange, invoice, customers }: Pro
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2"> <div className="flex w-full justify-end gap-2">
<Button <Button
type="button" type="button"
+2
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getActiveContext } from "@/lib/appwrite/active-context"; import { getActiveContext } from "@/lib/appwrite/active-context";
import { getLogoUrl } from "@/lib/appwrite/storage";
import { DashboardShell } from "./dashboard-shell"; import { DashboardShell } from "./dashboard-shell";
export default async function DashboardLayout({ export default async function DashboardLayout({
@@ -14,6 +15,7 @@ export default async function DashboardLayout({
const company = { const company = {
id: ctx.tenantId, id: ctx.tenantId,
name: ctx.settings?.companyName ?? "Çalışma alanı", name: ctx.settings?.companyName ?? "Çalışma alanı",
logoUrl: getLogoUrl(ctx.settings?.logo) ?? null,
}; };
const user = { const user = {
id: ctx.user.id, id: ctx.user.id,
+284 -16
View File
@@ -1,24 +1,292 @@
import { PricingPlans } from "@/components/pricing-plans" import { redirect } from "next/navigation";
import { FeaturesGrid } from "./components/features-grid" import { Building2, Check, Clock, Crown, Sparkles, Stethoscope } from "lucide-react";
import { FAQSection } from "./components/faq-section"
// Import data import { Badge } from "@/components/ui/badge";
import featuresData from "./data/features.json" import { Button } from "@/components/ui/button";
import faqsData from "./data/faqs.json" import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import {
RESOURCE_LABELS,
getEffectivePlan,
getPlanUsage,
type PlanResource,
} from "@/lib/appwrite/plan-limits";
import {
downgradeToFreeAction,
startMockCheckoutAction,
} from "@/lib/appwrite/subscription-actions";
import { PLAN_CATALOG } from "@/lib/appwrite/subscription-types";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
const trFmt = new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
maximumFractionDigits: 0,
});
type EcosystemTier = {
id: "klinik" | "ajans";
name: string;
description: string;
Icon: typeof Stethoscope;
features: string[];
};
const ECOSYSTEM_TIERS: EcosystemTier[] = [
{
id: "klinik",
name: "Kliniğim",
description: "Hekim, klinik ve sağlık merkezleri için.",
Icon: Stethoscope,
features: [
"Hasta kaydı + KVKK uyumlu dosyalama",
"Randevu + hatırlatma",
"Reçete ve tetkik takibi",
"Klinik finans paneli",
],
},
{
id: "ajans",
name: "Ajansım",
description: "Yaratıcı ajanslar ve danışmanlıklar için.",
Icon: Building2,
features: [
"Proje + saat takibi",
"Müşteri portalı",
"Brief + onay akışı",
"Ajans bazlı raporlama",
],
},
];
export default async function PricingPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const currentPlan = getEffectivePlan(ctx);
const isPro = currentPlan === "pro";
const canManage = ctx.role === "owner";
const usage = await getPlanUsage(ctx);
const resources: PlanResource[] = ["customers", "financeEntries", "software", "members"];
const tiers = [
{
...PLAN_CATALOG.free,
isCurrent: !isPro,
isPopular: false,
},
{
...PLAN_CATALOG.pro,
isCurrent: isPro,
isPopular: true,
},
];
export default function PricingPage() {
return ( return (
<div className="px-4 lg:px-6"> <div className="flex-1 space-y-8 px-6 pt-0">
{/* Pricing Cards */} <div className="flex flex-col gap-1">
<section className='pb-12' id='pricing'> <p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<PricingPlans mode="pricing" /> <h1 className="text-2xl font-bold tracking-tight">Plan</h1>
<p className="text-muted-foreground text-sm">
İşletmem'i ölçeğine göre kullan. Sektörel paketler (Kliniğim, Ajansım) yakında.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Bu ayki kullanımın</CardTitle>
<CardDescription>
Mevcut planın sınırlarına ne kadar yaklaştığını gör.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{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 (
<div key={r} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="capitalize">{RESOURCE_LABELS[r]}</span>
<span
className={cn(
"font-mono text-xs",
u.reached
? "text-destructive font-semibold"
: "text-muted-foreground",
)}
>
{u.used} / {limitLabel}
</span>
</div>
{u.limit !== Number.POSITIVE_INFINITY && (
<Progress value={pct} className="h-1.5" />
)}
</div>
);
})}
</CardContent>
</Card>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">İşletmem planları</h2>
<Badge variant="outline" className="text-xs">
Tek para birimi: ₺ (TRY)
</Badge>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{tiers.map((tier) => (
<Card
key={tier.id}
className={cn("flex flex-col pt-0", {
"border-primary relative shadow-lg": tier.isPopular,
"border-primary": tier.isCurrent,
})}
>
{tier.isCurrent && (
<div className="absolute start-0 -top-3 w-full">
<Badge className="mx-auto flex w-fit gap-1.5 rounded-full font-medium">
<Sparkles className="!size-4" />
Mevcut plan
</Badge>
</div>
)}
{tier.isPopular && !tier.isCurrent && (
<div className="absolute start-0 -top-3 w-full">
<Badge variant="secondary" className="mx-auto flex w-fit gap-1.5 rounded-full font-medium">
<Crown className="!size-4" />
Önerilen
</Badge>
</div>
)}
<CardHeader className="space-y-2 pt-8 text-center">
<CardTitle className="text-2xl">{tier.name}</CardTitle>
<p className="text-muted-foreground text-sm text-balance">{tier.description}</p>
</CardHeader>
<CardContent className="flex flex-1 flex-col space-y-6">
<div className="flex items-baseline justify-center">
<span className="text-4xl font-bold">{trFmt.format(tier.price)}</span>
<span className="text-muted-foreground text-sm">/ay</span>
</div>
<div className="space-y-2">
{tier.features.map((feature) => (
<div key={feature} className="flex items-center gap-2">
<div className="bg-muted rounded-full p-1">
<Check className="size-3.5" />
</div>
<span className="text-sm">{feature}</span>
</div>
))}
</div>
</CardContent>
<CardFooter>
{tier.isCurrent ? (
<Button className="w-full" size="lg" variant="outline" disabled>
Mevcut plan
</Button>
) : !canManage ? (
<Button className="w-full" size="lg" variant="outline" disabled>
Sahip yetkisi gerekli
</Button>
) : tier.id === "pro" ? (
<form action={startMockCheckoutAction} className="w-full">
<input type="hidden" name="plan" value="pro" />
<Button type="submit" className="w-full" size="lg">
<Crown className="size-4" />
Pro'ya geç (Test)
</Button>
</form>
) : (
<form action={downgradeToFreeAction} className="w-full">
<Button type="submit" className="w-full" size="lg" variant="outline">
Ücretsiz'e dön
</Button>
</form>
)}
</CardFooter>
</Card>
))}
</div>
</section> </section>
{/* Features Section */} <section className="space-y-3">
<FeaturesGrid features={featuresData} /> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Ekosistem paketleri</h2>
<Badge variant="outline" className="text-xs">
<Clock className="size-3" />
Yakında
</Badge>
</div>
<p className="text-muted-foreground text-sm">
Sektörel modüller İşletmem'in üzerine eklenecek. Aynı hesabınla farklı şirketleri tek
panelden yöneteceksin.
</p>
{/* FAQ Section */} <div className="grid gap-6 lg:grid-cols-2">
<FAQSection faqs={faqsData} /> {ECOSYSTEM_TIERS.map((t) => (
<Card key={t.id} className="flex flex-col bg-muted/30">
<CardHeader>
<div className="mb-2 flex items-center justify-between">
<div className="bg-background flex size-10 items-center justify-center rounded-md border">
<t.Icon className="size-5" />
</div>
<Badge variant="outline" className="text-xs">
Yakında
</Badge>
</div>
<CardTitle className="text-xl">{t.name}</CardTitle>
<p className="text-muted-foreground text-sm">{t.description}</p>
</CardHeader>
<CardContent className="flex flex-1 flex-col">
<div className="space-y-2">
{t.features.map((feature) => (
<div key={feature} className="flex items-center gap-2">
<div className="bg-background rounded-full p-1">
<Check className="size-3.5 text-muted-foreground" />
</div>
<span className="text-muted-foreground text-sm">{feature}</span>
</div>
))}
</div>
</CardContent>
<CardFooter>
<Button className="w-full" size="lg" variant="outline" disabled>
Geliştirme aşamasında
</Button>
</CardFooter>
</Card>
))}
</div>
</section>
<Card className="bg-muted/20">
<CardContent className="text-muted-foreground py-4 text-xs">
<p>
<span className="text-foreground font-medium">Test modu:</span> Pro plan şu anda mock
ödeme akışıyla çalışır. Shopier entegrasyonu yakında gerçek tahsilat ancak entegrasyon
tamamlandıktan sonra başlayacak.
</p>
</CardContent>
</Card>
</div> </div>
) );
} }
@@ -172,7 +172,7 @@ export function ServiceFormSheet({
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2"> <div className="flex w-full justify-end gap-2">
<Button <Button
type="button" type="button"
@@ -0,0 +1,151 @@
"use client";
import { Cpu, Wifi } from "lucide-react";
import { cn } from "@/lib/utils";
type Brand = "visa" | "mastercard" | "amex" | "troy" | "unknown";
function detectBrand(num: string): Brand {
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";
}
function BrandLogo({ brand }: { brand: Brand }) {
const base = "text-white/95 font-black tracking-tight";
if (brand === "visa") {
return <span className={cn(base, "text-2xl italic font-serif")}>VISA</span>;
}
if (brand === "mastercard") {
return (
<div className="flex items-center">
<div className="size-7 rounded-full bg-red-500" />
<div className="-ml-3 size-7 rounded-full bg-amber-400 mix-blend-screen" />
</div>
);
}
if (brand === "amex") {
return <span className={cn(base, "text-base")}>AMEX</span>;
}
if (brand === "troy") {
return <span className={cn(base, "text-xl tracking-widest")}>troy</span>;
}
return (
<div className="size-8 rounded-full border-2 border-white/40 border-dashed" />
);
}
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 (
<div className="w-full" style={{ perspective: "1200px" }}>
<div
className={cn(
"relative aspect-[1.586/1] w-full transition-transform duration-700 will-change-transform",
)}
style={{
transformStyle: "preserve-3d",
transform: flipped ? "rotateY(180deg)" : "rotateY(0deg)",
}}
>
{/* FRONT */}
<div
className="absolute inset-0 rounded-2xl p-5 sm:p-6 shadow-xl overflow-hidden"
style={{
backfaceVisibility: "hidden",
background:
"linear-gradient(135deg, oklch(0.32 0.04 260) 0%, oklch(0.20 0.04 260) 60%, oklch(0.12 0.03 260) 100%)",
}}
>
<div
className="absolute inset-0 opacity-30 mix-blend-overlay"
style={{
background:
"radial-gradient(circle at 20% 0%, rgba(255,255,255,0.4), transparent 40%), radial-gradient(circle at 80% 100%, rgba(255,255,255,0.2), transparent 50%)",
}}
/>
<div className="relative flex h-full flex-col justify-between text-white">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Cpu className="size-9 text-amber-300/80" strokeWidth={1.25} />
<Wifi className="size-5 rotate-90 text-white/60" />
</div>
<BrandLogo brand={brand} />
</div>
<div className="space-y-3">
<div className="font-mono text-lg tracking-[0.2em] sm:text-xl">
{display}
</div>
<div className="grid grid-cols-[1fr_auto] gap-4 text-xs sm:text-sm">
<div>
<div className="text-white/50 text-[10px] uppercase tracking-wider">
Kart sahibi
</div>
<div className="font-medium uppercase truncate">
{name || "AD SOYAD"}
</div>
</div>
<div className="text-right">
<div className="text-white/50 text-[10px] uppercase tracking-wider">
Son kullanma
</div>
<div className="font-mono">{expiry || "AA/YY"}</div>
</div>
</div>
</div>
</div>
</div>
{/* BACK */}
<div
className="absolute inset-0 rounded-2xl shadow-xl overflow-hidden"
style={{
backfaceVisibility: "hidden",
transform: "rotateY(180deg)",
background:
"linear-gradient(135deg, oklch(0.28 0.04 260) 0%, oklch(0.18 0.04 260) 60%, oklch(0.10 0.03 260) 100%)",
}}
>
<div className="mt-6 h-12 w-full bg-black/80" />
<div className="px-5 pt-5 sm:px-6">
<div className="flex items-center gap-3">
<div className="bg-white/90 flex h-9 flex-1 items-center justify-end rounded-sm px-3 font-mono text-sm text-slate-800">
{cvcDisplay}
</div>
<div className="text-white/70 text-[10px] uppercase tracking-wider">
CVC
</div>
</div>
<div className="text-white/40 mt-6 text-[10px] leading-relaxed">
Bu kart yalnızca İşletmem mock test akışı içindir. Gerçek bir banka kartı
değildir, hiçbir tahsilat yapılmaz.
</div>
</div>
</div>
</div>
</div>
);
}
@@ -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<CardBrand, string> = {
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<string>(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 (
<div className="grid gap-6 lg:grid-cols-[minmax(0,420px)_1fr]">
<div className="space-y-4">
<CreditCardVisual
number={visualNumber}
name={visualName}
expiry={visualExpiry}
cvc={visualCvc}
flipped={flipped}
/>
<div className="bg-emerald-500/5 flex items-start gap-2 rounded-md border p-3 text-xs">
<ShieldCheck className="size-4 text-emerald-600 shrink-0" />
<span className="text-muted-foreground">
Test modu gerçek kart bilgisi gerekmez. Onayladığında plan{" "}
<span className="text-foreground font-medium">{planPeriod}</span> boyunca aktif
olur, tahsilat yapılmaz.
</span>
</div>
</div>
<div className="space-y-5">
<div className="space-y-1">
<div className="text-muted-foreground text-xs uppercase tracking-wider">
Ödenecek tutar
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{trFmt.format(amount)}</span>
<span className="text-muted-foreground text-sm">{planName}</span>
</div>
</div>
{savedCards.length > 0 && (
<div className="grid grid-cols-2 gap-2 rounded-md border p-1">
<button
type="button"
className={cn(
"rounded px-3 py-2 text-sm font-medium transition-colors",
mode === "saved"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => setMode("saved")}
disabled={busy}
>
Kayıtlı kart
</button>
<button
type="button"
className={cn(
"rounded px-3 py-2 text-sm font-medium transition-colors",
mode === "new"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => setMode("new")}
disabled={busy}
>
Yeni kart
</button>
</div>
)}
{mode === "saved" && savedCards.length > 0 ? (
<div className="space-y-2">
{savedCards.map((c) => (
<label
key={c.$id}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-md border p-3 transition-colors",
selectedCardId === c.$id
? "border-primary bg-primary/5"
: "hover:bg-muted/50",
)}
>
<input
type="radio"
name="savedCard"
className="size-4"
checked={selectedCardId === c.$id}
onChange={() => setSelectedCardId(c.$id)}
disabled={busy}
/>
<CreditCard className="text-muted-foreground size-5 shrink-0" />
<div className="flex-1 text-sm">
<div className="font-medium">
{BRAND_LABEL[c.brand ?? "unknown"]} {c.last4}
</div>
<div className="text-muted-foreground text-xs">
{c.holderName ?? "İsimsiz"} · Son kullanma{" "}
{String(c.expiryMonth).padStart(2, "0")}/{String(c.expiryYear).slice(2)}
</div>
</div>
{c.isDefault && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">
Varsayılan
</span>
)}
</label>
))}
</div>
) : (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cc-number">Kart numarası</Label>
<Input
id="cc-number"
inputMode="numeric"
autoComplete="cc-number"
placeholder="1234 5678 9012 3456"
value={number}
onFocus={() => setFlipped(false)}
onChange={(e) => setNumber(formatNumber(e.target.value))}
className="font-mono tracking-wider"
disabled={busy}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cc-name">Kart üzerindeki ad</Label>
<Input
id="cc-name"
autoComplete="cc-name"
placeholder="AD SOYAD"
value={name}
onFocus={() => setFlipped(false)}
onChange={(e) => setName(e.target.value.toUpperCase())}
className="uppercase"
disabled={busy}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="cc-expiry">Son kullanma</Label>
<Input
id="cc-expiry"
inputMode="numeric"
autoComplete="cc-exp"
placeholder="AA/YY"
value={expiry}
onFocus={() => setFlipped(false)}
onChange={(e) => setExpiry(formatExpiry(e.target.value))}
className="font-mono"
disabled={busy}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cc-cvc">CVC</Label>
<Input
id="cc-cvc"
inputMode="numeric"
autoComplete="cc-csc"
placeholder="123"
value={cvc}
onFocus={() => setFlipped(true)}
onBlur={() => setFlipped(false)}
onChange={(e) => setCvc(formatCvc(e.target.value))}
className="font-mono"
disabled={busy}
/>
</div>
</div>
<label className="flex items-start gap-3 rounded-md border bg-muted/30 p-3 text-sm">
<Checkbox
id="save-card"
checked={saveCard}
onCheckedChange={(v) => setSaveCard(Boolean(v))}
disabled={busy}
className="mt-0.5"
/>
<div className="flex-1">
<div className="font-medium">Bu kartı kaydet</div>
<div className="text-muted-foreground mt-0.5 text-xs">
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.
</div>
</div>
</label>
</div>
)}
<div className="flex flex-col gap-2 pt-2 sm:flex-row">
<Button
type="button"
size="lg"
className="flex-1"
onClick={handleConfirm}
disabled={busy || !filled}
>
{confirming ? (
<>
<Loader2 className="size-4 animate-spin" />
Onaylanıyor...
</>
) : (
<>
<Lock className="size-4" />
Güvenli ödeme {trFmt.format(amount)}
</>
)}
</Button>
<Button
type="button"
size="lg"
variant="outline"
onClick={handleCancel}
disabled={busy}
>
{cancelling ? (
<Loader2 className="size-4 animate-spin" />
) : (
<X className="size-4" />
)}
Vazgeç
</Button>
</div>
{!filled && mode === "new" && (
<p className="text-muted-foreground text-xs">
Onay butonu etkin olması için tüm kart alanlarını doldurman gerekir. Test modu
herhangi bir 16 haneli numara çalışır.
</p>
)}
<Link
href="/settings/billing"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<ArrowLeft className="size-3" />
Plan & Faturalandırma'ya dön
</Link>
</div>
</div>
);
}
@@ -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 (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<Link
href="/settings/billing"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-sm"
>
<ArrowLeft className="size-3.5" />
Plan & Faturalandırma
</Link>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight">Ödemeyi tamamla</h1>
<Badge
variant="outline"
className="border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400"
>
Mock Test
</Badge>
</div>
<p className="text-muted-foreground text-sm">
Sipariş No: <span className="font-mono">{payment.orderId}</span> · Aşağıdaki kart
formunu doldur alanlar gerçek zamanlı olarak karta yansır.
</p>
</div>
<Card>
<CardContent className="py-6">
<MockPaymentForm
orderId={payment.orderId}
amount={payment.amount}
planName={plan.name}
planPeriod="30 gün"
savedCards={savedCards}
/>
</CardContent>
</Card>
</div>
);
}
+365 -44
View File
@@ -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 { Badge } from "@/components/ui/badge";
import { PricingPlans } from "@/components/pricing-plans" import { Button } from "@/components/ui/button";
import { CurrentPlanCard } from "./components/current-plan-card" import {
import { BillingHistoryCard } from "./components/billing-history-card" 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 const trFmt = new Intl.NumberFormat("tr-TR", {
import currentPlanData from "./data/current-plan.json" style: "currency",
import billingHistoryData from "./data/billing-history.json" currency: "TRY",
maximumFractionDigits: 0,
});
export default function BillingSettings() { const dateFmt = new Intl.DateTimeFormat("tr-TR", {
const handlePlanSelect = (planId: string) => { day: "2-digit",
console.log('Plan selected:', planId) month: "short",
// Handle plan selection logic here year: "numeric",
} });
const STATUS_LABEL: Record<string, string> = {
pending: "Bekliyor",
success: "Başarılı",
failed: "İptal",
refunded: "İade",
};
const STATUS_VARIANT: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
pending: "secondary",
success: "default",
failed: "outline",
refunded: "destructive",
};
const PROVIDER_LABEL: Record<string, string> = {
mock: "Mock (Test)",
shopier: "Shopier",
};
const BRAND_LABEL: Record<CardBrand, string> = {
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 ( return (
<div className="space-y-6 px-4 lg:px-6"> <div className="flex-1 space-y-6 px-6 pt-0">
<div> <div className="flex flex-col gap-1">
<h1 className="text-3xl font-bold">Plans & Billing</h1> <p className="text-muted-foreground text-sm">
<p className="text-muted-foreground"> {ctx.settings?.companyName ?? "Çalışma alanı"}
Manage your subscription and billing information. </p>
</p> <h1 className="text-2xl font-bold tracking-tight">Faturalandırma</h1>
</div> <p className="text-muted-foreground text-sm">
Kayıtlı ödeme yöntemlerini, faturalarını ve veri saklama bilgilerini buradan yönet.
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2"> </p>
<CurrentPlanCard plan={currentPlanData} />
<BillingHistoryCard history={billingHistoryData} />
</div>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Available Plans</CardTitle>
<CardDescription>
Choose a plan that works best for you.
</CardDescription>
</CardHeader>
<CardContent>
<PricingPlans
mode="billing"
currentPlanId="professional"
onPlanSelect={handlePlanSelect}
/>
</CardContent>
</Card>
</div>
</div> </div>
)
{sp.upgraded && (
<Card className="border-emerald-500/40 bg-emerald-500/5">
<CardContent className="flex items-center gap-3 py-4 text-sm">
<CheckCircle2 className="size-5 text-emerald-600" />
<span>Pro plan aktif. Sınırsız kullanım açıldı.</span>
</CardContent>
</Card>
)}
{sp.cancelled && (
<Card className="border-amber-500/40 bg-amber-500/5">
<CardContent className="py-4 text-sm">
Ödeme iptal edildi. Plan değişmedi.
</CardContent>
</Card>
)}
{sp.downgraded && (
<Card className="border-amber-500/40 bg-amber-500/5">
<CardContent className="py-4 text-sm">Ücretsiz plana döndünüz.</CardContent>
</Card>
)}
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
{isPro ? (
<Crown className="size-5 text-amber-500" />
) : (
<Sparkles className="size-5 text-muted-foreground" />
)}
{catalog.name} plan
</CardTitle>
<CardDescription>
{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ç."}
</CardDescription>
</div>
<Button asChild variant="outline" size="sm">
<Link href="/pricing">
Planları gör
<ArrowUpRight className="size-3.5" />
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{trFmt.format(catalog.price)}
<span className="text-muted-foreground text-sm font-normal"> /ay</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle>Kayıtlı kartlar</CardTitle>
<CardDescription>
Sonraki ödemelerde kullanılacak ödeme yöntemleri.
</CardDescription>
</div>
{canManage && !isPro && (
<Button asChild variant="outline" size="sm">
<Link href="/pricing">
Pro'ya geç
<ArrowUpRight className="size-3.5" />
</Link>
</Button>
)}
</div>
</CardHeader>
<CardContent>
{savedCards.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-8 text-center text-sm">
<CreditCard className="size-8 opacity-40" />
<p>Henüz kayıtlı kart yok.</p>
<p className="text-xs">
Bir ödeme yaparken "Bu kartı kaydet" seçeneğini işaretle, kart bilgileri buraya
eklenir.
</p>
</div>
) : (
<ul className="divide-y">
{savedCards.map((c) => (
<li
key={c.$id}
className={cn(
"flex items-center gap-4 py-3",
c.isDefault && "bg-primary/5 -mx-2 rounded-md px-2",
)}
>
<div className="bg-muted flex size-10 shrink-0 items-center justify-center rounded-md">
<CreditCard className="size-5" />
</div>
<div className="flex-1 text-sm">
<div className="flex items-center gap-2 font-medium">
{BRAND_LABEL[c.brand ?? "unknown"]} •••• {c.last4}
{c.isDefault && (
<Badge variant="secondary" className="gap-1 text-[10px]">
<Star className="!size-3" />
Varsayılan
</Badge>
)}
{c.provider === "mock" && (
<Badge variant="outline" className="text-[10px]">
Mock
</Badge>
)}
</div>
<div className="text-muted-foreground text-xs">
{c.holderName ?? "İsimsiz"} · Son kullanma{" "}
{String(c.expiryMonth).padStart(2, "0")}/{String(c.expiryYear).slice(2)}
</div>
</div>
{canManage && (
<div className="flex gap-1">
{!c.isDefault && (
<form action={setDefaultCardAction}>
<input type="hidden" name="id" value={c.$id} />
<Button
type="submit"
variant="ghost"
size="sm"
className="text-xs"
>
Varsayılan yap
</Button>
</form>
)}
<form action={removeCardAction}>
<input type="hidden" name="id" value={c.$id} />
<Button
type="submit"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-3.5" />
Sil
</Button>
</form>
</div>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card className="bg-muted/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="size-4 text-emerald-600" />
Kart bilgileriniz nasıl saklanır?
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<Lock className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">Ham kart numarası saklanmaz</div>
<p className="text-muted-foreground text-xs">
İş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.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<ShieldCheck className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">Mock test modu</div>
<p className="text-muted-foreground text-xs">
Ş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.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CreditCard className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">Shopier entegrasyonu sonrası</div>
<p className="text-muted-foreground text-xs">
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.
</p>
</div>
</div>
<div className="text-muted-foreground border-t pt-3 text-xs">
KVKK Aydınlatma Metni ve Mesafeli Satış Sözleşmesi yakında bu sayfaya eklenecek.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Ödeme geçmişi</CardTitle>
<CardDescription>Son 10 işlem.</CardDescription>
</CardHeader>
<CardContent>
{payments.length === 0 ? (
<p className="text-muted-foreground py-8 text-center text-sm">
Henüz ödeme kaydı yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarih</TableHead>
<TableHead>Sipariş No</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Sağlayıcı</TableHead>
<TableHead>Durum</TableHead>
<TableHead className="text-right">Tutar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{payments.map((p) => (
<TableRow key={p.$id}>
<TableCell className="text-muted-foreground">
{dateFmt.format(new Date(p.$createdAt))}
</TableCell>
<TableCell className="font-mono text-xs">{p.orderId}</TableCell>
<TableCell className="capitalize">{p.plan}</TableCell>
<TableCell>{PROVIDER_LABEL[p.provider ?? "mock"]}</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANT[p.status ?? "pending"]}>
{STATUS_LABEL[p.status ?? "pending"]}
</Badge>
{p.status === "pending" && (
<Link
href={`/settings/billing/checkout/${p.orderId}`}
className="text-primary ml-2 inline-flex items-center gap-0.5 text-xs hover:underline"
>
Devam <ArrowUpRight className="size-3" />
</Link>
)}
</TableCell>
<TableCell className="text-right font-mono">
{trFmt.format(p.amount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
} }
@@ -14,19 +14,24 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
import { inviteMemberAction } from "@/lib/appwrite/team-actions"; import { inviteMemberAction } from "@/lib/appwrite/team-actions";
import { initialInviteState } from "@/lib/appwrite/team-types"; import { initialInviteState } from "@/lib/appwrite/team-types";
export function InviteForm() { export function InviteForm() {
const [state, formAction, isPending] = useActionState(inviteMemberAction, initialInviteState); const [state, formAction, isPending] = useActionState(inviteMemberAction, initialInviteState);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [planLimitOpen, setPlanLimitOpen] = useState(false);
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
useEffect(() => { useEffect(() => {
if (state.ok && formRef.current) { if (state.ok && formRef.current) {
formRef.current.reset(); formRef.current.reset();
} }
}, [state.ok, state.shortUrl]); if (state.code === "PLAN_LIMIT_EXCEEDED") {
setPlanLimitOpen(true);
}
}, [state.ok, state.shortUrl, state.code]);
const copy = async () => { const copy = async () => {
if (!state.shortUrl) return; if (!state.shortUrl) return;
@@ -88,7 +93,7 @@ export function InviteForm() {
</div> </div>
</form> </form>
{state.error && ( {state.error && state.code !== "PLAN_LIMIT_EXCEEDED" && (
<p className="text-destructive mt-3 text-sm" role="alert"> <p className="text-destructive mt-3 text-sm" role="alert">
{state.error} {state.error}
</p> </p>
@@ -109,6 +114,11 @@ export function InviteForm() {
</div> </div>
)} )}
</CardContent> </CardContent>
<PlanLimitDialog
open={planLimitOpen}
onOpenChange={setPlanLimitOpen}
message={state.error}
/>
</Card> </Card>
); );
} }
@@ -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<string | null>(currentLogoUrl);
const [dragOver, setDragOver] = useState(false);
const [selectedName, setSelectedName] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const inputRef = useRef<HTMLInputElement>(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<HTMLLabelElement>) => {
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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-4" />
Logo
</CardTitle>
<CardDescription>
Faturalarda, panel başlığında ve dış paylaşımlarda görünür. PNG, JPG, WebP veya SVG
en fazla 2 MB.
</CardDescription>
</CardHeader>
<CardContent>
<form ref={formRef} action={formAction} className="space-y-4">
<div className="grid items-start gap-6 md:grid-cols-[200px_1fr]">
<div className="bg-muted flex aspect-square w-full items-center justify-center overflow-hidden rounded-lg border">
{previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt={`${companyName} logo`}
className="size-full object-contain"
/>
) : (
<div className="text-muted-foreground flex flex-col items-center gap-2 p-4 text-center text-xs">
<Building2 className="size-8 opacity-40" />
<span>Henüz logo yok</span>
</div>
)}
</div>
<div className="space-y-3">
<label
onDragOver={(e) => {
e.preventDefault();
if (canEdit) setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={canEdit ? handleDrop : undefined}
className={cn(
"flex min-h-[120px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors",
dragOver && "border-primary bg-primary/5",
!canEdit && "cursor-not-allowed opacity-60",
!dragOver && "hover:bg-muted/30",
)}
>
<input
ref={inputRef}
type="file"
name="logo"
accept={ALLOWED_MIME.join(",")}
className="sr-only"
disabled={!canEdit || busy}
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
/>
<ImagePlus className="text-muted-foreground size-6" />
<div className="text-sm font-medium">
{selectedName ?? "Logo yüklemek için tıkla veya sürükle bırak"}
</div>
<div className="text-muted-foreground text-xs">
Önerilen: kare, en az 256×256 px, şeffaf arka plan (PNG/SVG)
</div>
</label>
<div className="flex flex-wrap gap-2">
{canEdit && (
<Button type="submit" disabled={submitDisabled}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Yükleniyor...
</>
) : (
<>
<Upload className="size-4" />
Yükle
</>
)}
</Button>
)}
{canEdit && currentLogoUrl && (
<Button
type="button"
variant="outline"
onClick={handleRemove}
disabled={busy}
className="text-destructive hover:text-destructive"
>
{removing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
Kaldır
</Button>
)}
{!canEdit && (
<p className="text-muted-foreground text-xs">
Logo değiştirmek için yönetici yetkisi gerekli.
</p>
)}
</div>
</div>
</div>
</form>
</CardContent>
</Card>
);
}
@@ -1,7 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getLogoUrl } from "@/lib/appwrite/storage";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { LogoUploader } from "./components/logo-uploader";
import { WorkspaceSettingsForm } from "./components/workspace-form"; import { WorkspaceSettingsForm } from "./components/workspace-form";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -29,6 +31,12 @@ export default async function WorkspaceSettingsPage() {
</p> </p>
</div> </div>
<LogoUploader
canEdit={canEdit}
currentLogoUrl={getLogoUrl(ctx.settings?.logo)}
companyName={ctx.settings?.companyName ?? "Çalışma alanı"}
/>
<WorkspaceSettingsForm <WorkspaceSettingsForm
canEdit={canEdit} canEdit={canEdit}
defaults={{ defaults={{
@@ -204,7 +204,7 @@ export function AssignmentFormSheet({
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2"> <div className="flex w-full justify-end gap-2">
<Button <Button
type="button" type="button"
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useActionState, useEffect } from "react"; import { useActionState, useEffect, useState } from "react";
import { Loader2, Save } from "lucide-react"; import { Loader2, Save } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -16,6 +16,7 @@ import {
SheetTitle, SheetTitle,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { PlanLimitDialog } from "@/components/billing/plan-limit-dialog";
import { import {
createSoftwareAction, createSoftwareAction,
updateSoftwareAction, updateSoftwareAction,
@@ -33,11 +34,14 @@ export function SoftwareFormSheet({ open, onOpenChange, software }: Props) {
const isEdit = Boolean(software); const isEdit = Boolean(software);
const action = isEdit ? updateSoftwareAction : createSoftwareAction; const action = isEdit ? updateSoftwareAction : createSoftwareAction;
const [state, formAction, isPending] = useActionState(action, initialSoftwareState); const [state, formAction, isPending] = useActionState(action, initialSoftwareState);
const [planLimitOpen, setPlanLimitOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (state.ok) { if (state.ok) {
toast.success(isEdit ? "Yazılım güncellendi." : "Yazılım eklendi."); toast.success(isEdit ? "Yazılım güncellendi." : "Yazılım eklendi.");
onOpenChange(false); onOpenChange(false);
} else if (state.code === "PLAN_LIMIT_EXCEEDED") {
setPlanLimitOpen(true);
} else if (state.error) { } else if (state.error) {
toast.error(state.error); toast.error(state.error);
} }
@@ -108,7 +112,7 @@ export function SoftwareFormSheet({ open, onOpenChange, software }: Props) {
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2"> <div className="flex w-full justify-end gap-2">
<Button <Button
type="button" type="button"
@@ -135,6 +139,11 @@ export function SoftwareFormSheet({ open, onOpenChange, software }: Props) {
</SheetFooter> </SheetFooter>
</form> </form>
</SheetContent> </SheetContent>
<PlanLimitDialog
open={planLimitOpen}
onOpenChange={setPlanLimitOpen}
message={state.error}
/>
</Sheet> </Sheet>
); );
} }
@@ -190,7 +190,7 @@ export function TaskFormSheet({
</div> </div>
</div> </div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4"> <SheetFooter className="border-t bg-muted/30 px-6 pt-4">
<div className="flex w-full justify-end gap-2"> <div className="flex w-full justify-end gap-2">
<Button <Button
type="button" type="button"
+14 -3
View File
@@ -149,9 +149,20 @@ export function AppSidebar({
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton size="lg" asChild> <SidebarMenuButton size="lg" asChild>
<Link href="/dashboard"> <Link href="/dashboard">
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"> {company.logoUrl ? (
<Logo size={20} className="text-current" /> <div className="bg-background flex aspect-square size-8 items-center justify-center overflow-hidden rounded-lg border">
</div> {/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={company.logoUrl}
alt={`${company.name} logo`}
className="size-full object-contain"
/>
</div>
) : (
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<Logo size={20} className="text-current" />
</div>
)}
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">İşletmem</span> <span className="truncate font-medium">İşletmem</span>
<span className="text-muted-foreground truncate text-xs">{company.name}</span> <span className="text-muted-foreground truncate text-xs">{company.name}</span>
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Crown className="size-5 text-amber-500" />
Ücretsiz plan sınırına ulaştınız
</DialogTitle>
<DialogDescription>
{message ?? "Yeni kayıt eklemek için Pro plana geçmeniz gerekiyor."}
</DialogDescription>
</DialogHeader>
<div className="rounded-md border bg-muted/30 p-3 text-sm space-y-1">
<div className="font-medium text-foreground">Pro plan ile gelen avantajlar</div>
<ul className="text-muted-foreground space-y-0.5 list-disc list-inside">
<li>Sınırsız müşteri, finans kaydı, yazılım</li>
<li>Sınırsız ekip üyesi</li>
<li>Audit log + öncelikli destek</li>
</ul>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Kapat
</Button>
<Button asChild>
<Link href="/settings/billing">
<Crown className="size-4" />
Pro'ya geç
</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+68
View File
@@ -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 (
<Card
className={
reached
? "border-destructive/40 bg-destructive/5"
: "border-amber-500/40 bg-amber-500/5"
}
>
<CardContent className="flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle
className={`size-4 mt-0.5 shrink-0 ${
reached ? "text-destructive" : "text-amber-600"
}`}
/>
<div>
<span className="font-medium">
{reached ? "Sınıra ulaşıldı" : "Sınıra yaklaşıyorsun"}:
</span>{" "}
<span className="text-muted-foreground">
{u.used} / {u.limit} {label}.
</span>
{reached && (
<span className="text-muted-foreground"> Yeni {label} eklemek için Pro'ya geç.</span>
)}
</div>
</div>
<Button asChild size="sm" variant={reached ? "default" : "outline"}>
<Link href="/settings/billing">
<Crown className="size-3.5" />
Pro'ya geç
</Link>
</Button>
</CardContent>
</Card>
);
}
+6 -3
View File
@@ -58,9 +58,9 @@ function SheetContent({
className={cn( 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", "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" && 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" && 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" && 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", "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" && side === "bottom" &&
@@ -93,7 +93,10 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-footer" data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn(
"mt-auto flex flex-col gap-2 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]",
className,
)}
{...props} {...props}
/> />
) )
+18
View File
@@ -5,6 +5,11 @@ import { AppwriteException, ID, Permission, Role } from "node-appwrite";
import { z } from "zod"; import { z } from "zod";
import { logAudit } from "./audit"; import { logAudit } from "./audit";
import {
isPlanLimitError,
planLimitMessage,
requirePlanCapacity,
} from "./plan-limits";
import { DATABASE_ID, TABLES, type Customer } from "./schema"; import { DATABASE_ID, TABLES, type Customer } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard"; import { requireTenant } from "./tenant-guard";
@@ -64,6 +69,19 @@ export async function createCustomerAction(
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) }; 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 { try {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
const row = await tablesDB.createRow( const row = await tablesDB.createRow(
+1
View File
@@ -2,6 +2,7 @@ export type CustomerActionState = {
ok: boolean; ok: boolean;
error?: string; error?: string;
fieldErrors?: Record<string, string>; fieldErrors?: Record<string, string>;
code?: "PLAN_LIMIT_EXCEEDED";
}; };
export const initialCustomerState: CustomerActionState = { ok: false }; export const initialCustomerState: CustomerActionState = { ok: false };
+18
View File
@@ -5,6 +5,11 @@ import { AppwriteException, ID } from "node-appwrite";
import { z } from "zod"; import { z } from "zod";
import { logAudit } from "./audit"; import { logAudit } from "./audit";
import {
isPlanLimitError,
planLimitMessage,
requirePlanCapacity,
} from "./plan-limits";
import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema"; import { DATABASE_ID, TABLES, type FinanceEntry } from "./schema";
import { canAccessRow, scopedRowPermissions } from "./scope-permissions"; import { canAccessRow, scopedRowPermissions } from "./scope-permissions";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
@@ -68,6 +73,19 @@ export async function createFinanceEntryAction(
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) }; 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 { try {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
const data = { ...parsed.data, date: toIso(parsed.data.date) }; const data = { ...parsed.data, date: toIso(parsed.data.date) };
+1
View File
@@ -2,6 +2,7 @@ export type FinanceActionState = {
ok: boolean; ok: boolean;
error?: string; error?: string;
fieldErrors?: Record<string, string>; fieldErrors?: Record<string, string>;
code?: "PLAN_LIMIT_EXCEEDED";
}; };
export const initialFinanceState: FinanceActionState = { ok: false }; export const initialFinanceState: FinanceActionState = { ok: false };
+177
View File
@@ -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<LogoActionState> {
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<LogoActionState> {
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 };
}
+123
View File
@@ -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<TenantPlan, Record<PlanResource, number>> = {
free: {
customers: 50,
financeEntries: 100,
software: 5,
members: 1,
},
pro: {
customers: INF,
financeEntries: INF,
software: INF,
members: INF,
},
};
export const RESOURCE_LABELS: Record<PlanResource, string> = {
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<number> {
const { tablesDB, teams } = createAdminClient();
if (resource === "members") {
const result = await teams.listMemberships(tenantId);
return result.total;
}
const tableMap: Record<Exclude<PlanResource, "members">, 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<PlanResource, { used: number; limit: number; reached: boolean }>;
};
export async function getPlanUsage(ctx: TenantContext): Promise<PlanUsage> {
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<void> {
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.`;
}
+155
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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");
}
+35
View File
@@ -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<SavedCard[]> {
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<SavedCard | null> {
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;
}
+43
View File
@@ -1,5 +1,9 @@
export const DATABASE_ID = "isletmem"; export const DATABASE_ID = "isletmem";
export const BUCKETS = {
tenantLogos: "tenant-logos",
} as const;
export const TABLES = { export const TABLES = {
tenantSettings: "tenant_settings", tenantSettings: "tenant_settings",
customers: "customers", customers: "customers",
@@ -18,6 +22,8 @@ export const TABLES = {
loanInstallments: "loan_installments", loanInstallments: "loan_installments",
creditCards: "credit_cards", creditCards: "credit_cards",
creditCardStatements: "credit_card_statements", creditCardStatements: "credit_card_statements",
subscriptionPayments: "subscription_payments",
savedCards: "saved_cards",
} as const; } as const;
export type TableId = (typeof TABLES)[keyof typeof TABLES]; export type TableId = (typeof TABLES)[keyof typeof TABLES];
@@ -36,6 +42,8 @@ type Row = SystemRow;
export type TenantRole = "owner" | "admin" | "member"; export type TenantRole = "owner" | "admin" | "member";
export type TenantPlan = "free" | "pro";
export interface TenantSettings extends Row { export interface TenantSettings extends Row {
tenantId: string; tenantId: string;
companyName: string; companyName: string;
@@ -47,6 +55,10 @@ export interface TenantSettings extends Row {
defaultVatRate?: number; defaultVatRate?: number;
invoicePrefix?: string; invoicePrefix?: string;
invoiceCounter?: number; invoiceCounter?: number;
plan?: TenantPlan;
planStartedAt?: string;
planExpiresAt?: string;
lastPaymentId?: string;
} }
export type CustomerStatus = "active" | "passive"; export type CustomerStatus = "active" | "passive";
@@ -263,6 +275,37 @@ export interface CreditCardStatement extends Row {
notes?: string; 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 InviteRole = "admin" | "member";
export type InviteStatus = "pending" | "accepted" | "cancelled" | "expired"; export type InviteStatus = "pending" | "accepted" | "cancelled" | "expired";
+18
View File
@@ -5,6 +5,11 @@ import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { z } from "zod"; import { z } from "zod";
import { logAudit } from "./audit"; import { logAudit } from "./audit";
import {
isPlanLimitError,
planLimitMessage,
requirePlanCapacity,
} from "./plan-limits";
import { import {
DATABASE_ID, DATABASE_ID,
TABLES, TABLES,
@@ -68,6 +73,19 @@ export async function createSoftwareAction(
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) }; 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 { try {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
const row = await tablesDB.createRow( const row = await tablesDB.createRow(
+1
View File
@@ -2,6 +2,7 @@ export type SoftwareActionState = {
ok: boolean; ok: boolean;
error?: string; error?: string;
fieldErrors?: Record<string, string>; fieldErrors?: Record<string, string>;
code?: "PLAN_LIMIT_EXCEEDED";
}; };
export const initialSoftwareState: SoftwareActionState = { ok: false }; export const initialSoftwareState: SoftwareActionState = { ok: false };
+16
View File
@@ -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);
}
+220
View File
@@ -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<void> {
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<SubscriptionPayment | null> {
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<void> {
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<void> {
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<void> {
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`);
}
+40
View File
@@ -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<SubscriptionPayment | null> {
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<SubscriptionPayment[]> {
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[];
}
+40
View File
@@ -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<TenantPlan, PlanCatalogEntry> = {
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",
],
},
};
+18
View File
@@ -5,6 +5,11 @@ import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite"; import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { logAudit } from "./audit"; import { logAudit } from "./audit";
import {
isPlanLimitError,
planLimitMessage,
requirePlanCapacity,
} from "./plan-limits";
import { DATABASE_ID, TABLES, type InviteLink, type InviteRole } from "./schema"; import { DATABASE_ID, TABLES, type InviteLink, type InviteRole } from "./schema";
import { createAdminClient, createSessionClient } from "./server"; import { createAdminClient, createSessionClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard"; import { requireRole, requireTenant } from "./tenant-guard";
@@ -66,6 +71,19 @@ export async function inviteMemberAction(
return { ok: false, error: "Kendinizi davet edemezsiniz." }; 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(); const admin = createAdminClient();
// 1. Kullanıcı zaten Appwrite'ta var mı? // 1. Kullanıcı zaten Appwrite'ta var mı?
+1
View File
@@ -3,6 +3,7 @@ export type InviteState = {
error?: string; error?: string;
shortUrl?: string; shortUrl?: string;
message?: string; message?: string;
code?: "PLAN_LIMIT_EXCEEDED";
}; };
export const initialInviteState: InviteState = { ok: false }; export const initialInviteState: InviteState = { ok: false };
+2
View File
@@ -58,6 +58,8 @@ export async function createWorkspaceAction(
companyName, companyName,
companyTaxId, companyTaxId,
companyPhone, companyPhone,
plan: "free",
planStartedAt: new Date().toISOString(),
}, },
[ [
Permission.read(Role.team(teamId)), Permission.read(Role.team(teamId)),