feat(banking C): credit cards + monthly statements

Final banking step. User-managed: each month, paste your bank statement
totals into the form. We don't auto-compute interest — bank's number is
authoritative.

Schema:
- credit_cards: bankName, cardName, last4, creditLimit, statementDay,
  dueDay, interestRate (monthly nominal %), bankAccountId (optional FK,
  payments expense from this account), archived, notes.
- credit_card_statements: cardId, period (YYYY-MM), statementDate,
  dueDate, totalDebt, minimumPayment, paidAmount, status
  (pending/partial/paid/overdue), financeEntryId (link to last payment).
- Indexes on (tenantId, archived) for cards and (tenantId, cardId) +
  (tenantId, status, dueDate) for statements.

Server (lib/appwrite/credit-card-actions.ts):
- create/update/archive(toggle)/delete for cards. Card delete cascades
  through statements + their finance_entries.
- createStatementAction: computes status from dueDate + paidAmount.
- payStatementAction: partial-payment friendly. Creates a single
  finance_entry expense for the pay amount, capped at remaining balance,
  bankAccountId carried from the card. Recomputes status: paid (full),
  partial (some + pre-due), overdue (past due with anything < total).
- deleteStatementAction: removes linked finance_entry too.
- All audit-logged.

UI (/finance/cards):
- 3 stat cards: aktif kart sayısı, bekleyen toplam, vadesi geçmiş ekstre.
- Per-card panel: bank/name/last4, limit + statement/due day + monthly
  interest, current outstanding. Statement table inside card with status
  badges, Öde button (opens partial-payment dialog), delete button.
- CardFormSheet for card CRUD, StatementFormSheet for statement creation
  with default period/dates derived from card's statementDay/dueDay.

Sidebar Finans submenu now functional: Banka hesapları → /finance/banks,
Krediler → /finance/loans, Kredi kartları → /finance/cards.
This commit is contained in:
kovakmedya
2026-04-30 07:36:31 +03:00
parent b632ae8a73
commit 121fbdba9d
10 changed files with 1719 additions and 0 deletions
@@ -0,0 +1,225 @@
"use client";
import { useActionState, useEffect } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import {
createCreditCardAction,
updateCreditCardAction,
} from "@/lib/appwrite/credit-card-actions";
import { initialCreditCardState } from "@/lib/appwrite/credit-card-types";
import type { BankAccountOption, CreditCardRow } from "./types";
const NONE = "__none__";
export function CardFormSheet({
open,
onOpenChange,
card,
bankAccounts,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
card?: CreditCardRow | null;
bankAccounts: BankAccountOption[];
}) {
const isEdit = Boolean(card);
const action = isEdit ? updateCreditCardAction : createCreditCardAction;
const [state, formAction, isPending] = useActionState(action, initialCreditCardState);
useEffect(() => {
if (state.ok) {
toast.success(isEdit ? "Kart güncellendi." : "Kart eklendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{isEdit ? "Kartı düzenle" : "Yeni kredi kartı"}</SheetTitle>
<SheetDescription>
Hesap kesim ve son ödeme günleri her ay otomatik kullanılır. Ekstreler kart başına manuel girilir.
</SheetDescription>
</SheetHeader>
<form
action={(fd) => {
if (fd.get("bankAccountId") === NONE) fd.set("bankAccountId", "");
formAction(fd);
}}
className="flex flex-1 flex-col"
>
{isEdit && card && <input type="hidden" name="id" value={card.id} />}
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="bankName">Banka *</Label>
<Input
id="bankName"
name="bankName"
defaultValue={card?.bankName ?? ""}
required
placeholder="Garanti BBVA"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cardName">Kart adı *</Label>
<Input
id="cardName"
name="cardName"
defaultValue={card?.cardName ?? ""}
required
placeholder="Bonus / Maximum / World"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="last4">Son 4 hane</Label>
<Input
id="last4"
name="last4"
defaultValue={card?.last4 ?? ""}
maxLength={4}
inputMode="numeric"
placeholder="1234"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="creditLimit">Kredi limiti ()</Label>
<Input
id="creditLimit"
name="creditLimit"
type="number"
step="0.01"
min="0"
defaultValue={card?.creditLimit ?? 0}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="statementDay">Hesap kesim günü</Label>
<Input
id="statementDay"
name="statementDay"
type="number"
min="1"
max="28"
defaultValue={card?.statementDay ?? 1}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dueDay">Son ödeme günü</Label>
<Input
id="dueDay"
name="dueDay"
type="number"
min="1"
max="28"
defaultValue={card?.dueDay ?? 10}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="interestRate">Aylık faiz %</Label>
<Input
id="interestRate"
name="interestRate"
type="number"
step="0.01"
min="0"
defaultValue={card?.interestRate ?? 4.25}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="bankAccountId">Bağlı hesap</Label>
<Select
name="bankAccountId"
defaultValue={card?.bankAccountId || NONE}
disabled={bankAccounts.length === 0}
>
<SelectTrigger id="bankAccountId">
<SelectValue placeholder={bankAccounts.length === 0 ? "Önce hesap ekleyin" : "Yok"} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Yok</SelectItem>
{bankAccounts.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Ekstre ödemeleri seçilen hesaba expense olarak yazılır.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea
id="notes"
name="notes"
rows={3}
defaultValue={card?.notes ?? ""}
placeholder="Sadakat puanı, kampanya, vb."
/>
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
<div className="flex w-full justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
{isEdit ? "Güncelle" : "Kaydet"}
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,522 @@
"use client";
import { useState, useTransition } from "react";
import {
Archive,
ArchiveRestore,
Check,
CreditCard as CreditCardIcon,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
archiveCreditCardAction,
deleteCreditCardAction,
deleteStatementAction,
payStatementAction,
} from "@/lib/appwrite/credit-card-actions";
import { formatDate, formatTRY } from "@/lib/format";
import { cn } from "@/lib/utils";
import { CardFormSheet } from "./card-form-sheet";
import { StatementFormSheet } from "./statement-form-sheet";
import {
type BankAccountOption,
type CreditCardRow,
STATEMENT_STATUS_COLOR,
STATEMENT_STATUS_LABEL,
type StatementRow,
} from "./types";
type Props = {
cards: CreditCardRow[];
statements: StatementRow[];
bankAccounts: BankAccountOption[];
};
export function CardsClient({ cards, statements, bankAccounts }: Props) {
const [cardFormOpen, setCardFormOpen] = useState(false);
const [editingCard, setEditingCard] = useState<CreditCardRow | null>(null);
const [deletingCard, setDeletingCard] = useState<CreditCardRow | null>(null);
const [stmtFormOpen, setStmtFormOpen] = useState(false);
const [stmtCard, setStmtCard] = useState<CreditCardRow | null>(null);
const [payDialog, setPayDialog] = useState<StatementRow | null>(null);
const [payAmount, setPayAmount] = useState("");
const [deletingStmt, setDeletingStmt] = useState<StatementRow | null>(null);
const [busy, startTransition] = useTransition();
const active = cards.filter((c) => !c.archived);
const archived = cards.filter((c) => c.archived);
const stmtsByCard = new Map<string, StatementRow[]>();
for (const s of statements) {
const arr = stmtsByCard.get(s.cardId) ?? [];
arr.push(s);
stmtsByCard.set(s.cardId, arr);
}
const totalOutstanding = statements
.filter((s) => s.status !== "paid")
.reduce((sum, s) => sum + (s.totalDebt - s.paidAmount), 0);
const overdueCount = statements.filter((s) => s.status === "overdue").length;
const toggleArchive = (c: CreditCardRow) => {
startTransition(async () => {
const fd = new FormData();
fd.set("id", c.id);
const r = await archiveCreditCardAction(fd);
if (r.ok) toast.success(c.archived ? "Kart geri açıldı." : "Kart arşivlendi.");
else toast.error(r.error ?? "İşlem başarısız.");
});
};
const handleDeleteCard = () => {
if (!deletingCard) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deletingCard.id);
const r = await deleteCreditCardAction(fd);
if (r.ok) {
toast.success("Kart silindi.");
setDeletingCard(null);
} else {
toast.error(r.error ?? "Silme başarısız.");
}
});
};
const handlePay = () => {
if (!payDialog) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", payDialog.id);
if (payAmount.trim()) fd.set("amount", payAmount);
const r = await payStatementAction(fd);
if (r.ok) {
toast.success("Ödeme kaydedildi.");
setPayDialog(null);
setPayAmount("");
} else {
toast.error(r.error ?? "Ödeme başarısız.");
}
});
};
const handleDeleteStmt = () => {
if (!deletingStmt) return;
startTransition(async () => {
const fd = new FormData();
fd.set("id", deletingStmt.id);
const r = await deleteStatementAction(fd);
if (r.ok) {
toast.success("Ekstre silindi.");
setDeletingStmt(null);
} else {
toast.error(r.error ?? "Silme başarısız.");
}
});
};
return (
<div className="space-y-6">
<div className="grid gap-3 md:grid-cols-3">
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Aktif kart</p>
<p className="mt-1 text-2xl font-semibold">{active.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Bekleyen toplam borç</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-amber-600 dark:text-amber-400">
{formatTRY(totalOutstanding)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground text-xs">Vadesi geçmiş ekstre</p>
<p
className={cn(
"mt-1 text-2xl font-semibold",
overdueCount > 0 && "text-red-600 dark:text-red-400",
)}
>
{overdueCount}
</p>
</CardContent>
</Card>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setStmtCard(null);
setStmtFormOpen(true);
}}
disabled={cards.length === 0}
>
<Plus className="size-4" />
Yeni ekstre
</Button>
<Button
onClick={() => {
setEditingCard(null);
setCardFormOpen(true);
}}
>
<Plus className="size-4" />
Yeni kart
</Button>
</div>
{cards.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-12 text-center">
<CreditCardIcon className="text-muted-foreground size-8" />
<p className="text-sm">Henüz kredi kartı eklenmemiş.</p>
<Button variant="outline" size="sm" onClick={() => setCardFormOpen(true)}>
<Plus className="size-3.5" />
İlk kartı ekle
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{active.map((c) => {
const items = stmtsByCard.get(c.id) ?? [];
const totalDebt = items
.filter((s) => s.status !== "paid")
.reduce((sum, s) => sum + (s.totalDebt - s.paidAmount), 0);
return (
<Card key={c.id}>
<CardContent className="space-y-3 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<CreditCardIcon className="text-muted-foreground size-4" />
<h3 className="font-semibold">{c.bankName}</h3>
<span className="text-muted-foreground">·</span>
<span>{c.cardName}</span>
{c.last4 && (
<span className="text-muted-foreground font-mono text-xs">
**{c.last4}
</span>
)}
</div>
<div className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
<span>Limit {formatTRY(c.creditLimit)}</span>
<span>Kesim: ayın {c.statementDay}'i</span>
<span>Vade: ayın {c.dueDay}'i</span>
<span>Aylık faiz: %{c.interestRate}</span>
</div>
{c.bankAccountLabel && (
<p className="text-muted-foreground mt-1 text-xs">
Hesap: {c.bankAccountLabel}
</p>
)}
</div>
<div className="flex items-center gap-2">
<div className="text-right">
<p className="text-muted-foreground text-xs">Bekleyen</p>
<p className="font-semibold tabular-nums">{formatTRY(totalDebt)}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8" disabled={busy}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setStmtCard(c);
setStmtFormOpen(true);
}}
>
<Plus className="size-3.5" />
Ekstre ekle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setEditingCard(c);
setCardFormOpen(true);
}}
>
<Pencil className="size-3.5" />
Düzenle
</DropdownMenuItem>
<DropdownMenuItem onClick={() => toggleArchive(c)}>
<Archive className="size-3.5" />
Arşivle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeletingCard(c)}
>
<Trash2 className="size-3.5" />
Sil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{items.length > 0 && (
<div className="border-t pt-3">
<Table>
<TableHeader>
<TableRow>
<TableHead>Dönem</TableHead>
<TableHead>Son ödeme</TableHead>
<TableHead className="text-right">Toplam</TableHead>
<TableHead className="text-right">Asgari</TableHead>
<TableHead className="text-right">Ödenen</TableHead>
<TableHead>Durum</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((s) => {
const remaining = s.totalDebt - s.paidAmount;
return (
<TableRow key={s.id}>
<TableCell className="font-mono text-sm">{s.period}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{formatDate(s.dueDate)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(s.totalDebt)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(s.minimumPayment)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatTRY(s.paidAmount)}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={cn("border-0", STATEMENT_STATUS_COLOR[s.status])}
>
{STATEMENT_STATUS_LABEL[s.status]}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{remaining > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setPayDialog(s);
setPayAmount(remaining.toFixed(2));
}}
>
<Check className="size-3.5" />
Öde
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => setDeletingStmt(s)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
})}
{archived.length > 0 && (
<details className="group">
<summary className="text-muted-foreground hover:text-foreground cursor-pointer text-sm">
Arşivlenmiş kartlar ({archived.length})
</summary>
<div className="mt-4 space-y-3">
{archived.map((c) => (
<Card key={c.id} className="opacity-70">
<CardContent className="flex items-center justify-between p-4">
<div>
<p className="font-medium">
{c.bankName} {c.cardName}{" "}
{c.last4 && (
<span className="text-muted-foreground font-mono text-xs">
**{c.last4}
</span>
)}
</p>
<p className="text-muted-foreground text-xs">Arşivli</p>
</div>
<Button variant="outline" size="sm" onClick={() => toggleArchive(c)}>
<ArchiveRestore className="size-3.5" />
Geri
</Button>
</CardContent>
</Card>
))}
</div>
</details>
)}
</div>
)}
<CardFormSheet
open={cardFormOpen}
onOpenChange={(v) => {
setCardFormOpen(v);
if (!v) setEditingCard(null);
}}
card={editingCard}
bankAccounts={bankAccounts}
/>
<StatementFormSheet
open={stmtFormOpen}
onOpenChange={(v) => {
setStmtFormOpen(v);
if (!v) setStmtCard(null);
}}
card={stmtCard}
cards={cards}
/>
<Dialog open={Boolean(deletingCard)} onOpenChange={(v) => !v && setDeletingCard(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kartı sil</DialogTitle>
<DialogDescription>
<strong>
{deletingCard?.bankName} {deletingCard?.cardName}
</strong>{" "}
ve tüm ekstreleri silinecek.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingCard(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDeleteCard} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(payDialog)}
onOpenChange={(v) => {
if (!v) {
setPayDialog(null);
setPayAmount("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Ekstre ödemesi</DialogTitle>
<DialogDescription>
{payDialog && (
<>
<strong>{payDialog.period}</strong> dönemi kalan{" "}
{formatTRY(payDialog.totalDebt - payDialog.paidAmount)}.
<br />
Tutarı boş bırakırsanız tamamı ödenir.
</>
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-2">
<label className="text-sm font-medium">Ödenen tutar ()</label>
<Input
type="number"
step="0.01"
min="0"
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPayDialog(null)} disabled={busy}>
Vazgeç
</Button>
<Button onClick={handlePay} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
Ödemeyi kaydet
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(deletingStmt)} onOpenChange={(v) => !v && setDeletingStmt(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ekstreyi sil</DialogTitle>
<DialogDescription>
<strong>{deletingStmt?.period}</strong> ekstresi silinecek. Bağlı gider kaydı varsa
o da silinir.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingStmt(null)} disabled={busy}>
Vazgeç
</Button>
<Button variant="destructive" onClick={handleDeleteStmt} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,197 @@
"use client";
import { useActionState, useEffect, useMemo } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { createStatementAction } from "@/lib/appwrite/credit-card-actions";
import { initialCreditCardState } from "@/lib/appwrite/credit-card-types";
import type { CreditCardRow } from "./types";
function pad(n: number) {
return String(n).padStart(2, "0");
}
function defaultDates(card?: CreditCardRow | null) {
const now = new Date();
const sd = card?.statementDay ?? 1;
const dd = card?.dueDay ?? 10;
const statement = new Date(now.getFullYear(), now.getMonth(), Math.min(sd, 28));
const due = new Date(now.getFullYear(), now.getMonth(), Math.min(dd, 28));
if (due.getTime() < statement.getTime()) due.setMonth(due.getMonth() + 1);
const period = `${statement.getFullYear()}-${pad(statement.getMonth() + 1)}`;
const ymd = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
return { period, statementDate: ymd(statement), dueDate: ymd(due) };
}
export function StatementFormSheet({
open,
onOpenChange,
card,
cards,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
card?: CreditCardRow | null;
cards: CreditCardRow[];
}) {
const [state, formAction, isPending] = useActionState(
createStatementAction,
initialCreditCardState,
);
const defaults = useMemo(() => defaultDates(card), [card]);
useEffect(() => {
if (state.ok) {
toast.success("Ekstre kaydedildi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>Yeni ekstre</SheetTitle>
<SheetDescription>
Banka ekstrenizdeki dönem, son ödeme tarihi, toplam borç ve asgari ödeme tutarını girin.
</SheetDescription>
</SheetHeader>
<form action={formAction} className="flex flex-1 flex-col">
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<div className="grid gap-2">
<Label htmlFor="cardId">Kart *</Label>
<Select name="cardId" defaultValue={card?.id ?? ""}>
<SelectTrigger id="cardId">
<SelectValue placeholder="Kart seçin" />
</SelectTrigger>
<SelectContent>
{cards.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.bankName} {c.cardName} {c.last4 ? `**${c.last4}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
{state.fieldErrors?.cardId && (
<p className="text-destructive text-xs">{state.fieldErrors.cardId}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="period">Dönem (YYYY-AA) *</Label>
<Input
id="period"
name="period"
defaultValue={defaults.period}
pattern="\d{4}-\d{2}"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="statementDate">Hesap kesim *</Label>
<Input
id="statementDate"
name="statementDate"
type="date"
defaultValue={defaults.statementDate}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dueDate">Son ödeme *</Label>
<Input
id="dueDate"
name="dueDate"
type="date"
defaultValue={defaults.dueDate}
required
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="totalDebt">Toplam borç () *</Label>
<Input
id="totalDebt"
name="totalDebt"
type="number"
step="0.01"
min="0"
required
placeholder="0.00"
/>
{state.fieldErrors?.totalDebt && (
<p className="text-destructive text-xs">{state.fieldErrors.totalDebt}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="minimumPayment">Asgari ödeme ()</Label>
<Input
id="minimumPayment"
name="minimumPayment"
type="number"
step="0.01"
min="0"
placeholder="0.00"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea id="notes" name="notes" rows={3} placeholder="Önemli ekstre notları" />
</div>
</div>
<SheetFooter className="border-t bg-muted/30 px-6 py-4">
<div className="flex w-full justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Vazgeç
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin" />
Kaydediliyor...
</>
) : (
<>
<Save className="size-4" />
Kaydet
</>
)}
</Button>
</div>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,43 @@
export type CreditCardRow = {
id: string;
bankName: string;
cardName: string;
last4: string;
creditLimit: number;
statementDay: number;
dueDay: number;
interestRate: number;
bankAccountId: string;
bankAccountLabel: string;
archived: boolean;
notes: string;
};
export type StatementRow = {
id: string;
cardId: string;
period: string; // YYYY-MM
statementDate: string;
dueDate: string;
totalDebt: number;
minimumPayment: number;
paidAmount: number;
status: "pending" | "partial" | "paid" | "overdue";
notes: string;
};
export type BankAccountOption = { id: string; label: string };
export const STATEMENT_STATUS_LABEL: Record<StatementRow["status"], string> = {
pending: "Bekliyor",
partial: "Kısmi ödendi",
paid: "Ödendi",
overdue: "Gecikti",
};
export const STATEMENT_STATUS_COLOR: Record<StatementRow["status"], string> = {
pending: "bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30",
partial: "bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30",
paid: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30",
overdue: "bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/30",
};
@@ -0,0 +1,80 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { listBankAccounts } from "@/lib/appwrite/bank-account-queries";
import {
listCreditCards,
listStatements,
} from "@/lib/appwrite/credit-card-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { CardsClient } from "./components/cards-client";
export const metadata: Metadata = {
title: "İşletmem — Kredi kartları",
};
export default async function CardsPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const [cards, statements, bankAccounts] = await Promise.all([
listCreditCards(ctx.tenantId),
listStatements(ctx.tenantId),
listBankAccounts(ctx.tenantId),
]);
const bankMap = new Map(
bankAccounts.map((b) => [b.$id, `${b.bankName}${b.accountName}`]),
);
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Kredi kartları</h1>
<p className="text-muted-foreground text-sm">
Kartlarınızı ve aylık ekstrelerinizi takip edin. Ekstre ödendiğinde otomatik gider kaydı oluşur.
</p>
</div>
<CardsClient
cards={cards.map((c) => ({
id: c.$id,
bankName: c.bankName,
cardName: c.cardName,
last4: c.last4 ?? "",
creditLimit: c.creditLimit ?? 0,
statementDay: c.statementDay ?? 1,
dueDay: c.dueDay ?? 10,
interestRate: c.interestRate ?? 4.25,
bankAccountId: c.bankAccountId ?? "",
bankAccountLabel: c.bankAccountId ? bankMap.get(c.bankAccountId) ?? "" : "",
archived: Boolean(c.archived),
notes: c.notes ?? "",
}))}
statements={statements.map((s) => ({
id: s.$id,
cardId: s.cardId,
period: s.period,
statementDate: s.statementDate,
dueDate: s.dueDate,
totalDebt: s.totalDebt,
minimumPayment: s.minimumPayment ?? 0,
paidAmount: s.paidAmount ?? 0,
status: s.status ?? "pending",
notes: s.notes ?? "",
}))}
bankAccounts={bankAccounts
.filter((b) => !b.archived)
.map((b) => ({
id: b.$id,
label: `${b.bankName}${b.accountName}`,
}))}
/>
</div>
);
}
+489
View File
@@ -0,0 +1,489 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import {
DATABASE_ID,
TABLES,
type CreditCard,
type CreditCardStatement,
} from "./schema";
import { createAdminClient } from "./server";
import { requireTenant } from "./tenant-guard";
import type { CreditCardActionState } from "./credit-card-types";
import { creditCardSchema, statementSchema } from "@/lib/validation/credit-cards";
function appwriteError(e: unknown): string {
if (e instanceof AppwriteException) return e.message || "Beklenmeyen hata.";
return "Bağlantı hatası. Tekrar deneyin.";
}
function flattenErrors(err: z.ZodError): Record<string, string> {
const out: Record<string, string> = {};
for (const issue of err.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
function teamRowPermissions(tenantId: string) {
return [
Permission.read(Role.team(tenantId)),
Permission.update(Role.team(tenantId)),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
];
}
function toIso(v: string): string {
if (!v) return v;
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return `${v}T00:00:00.000+00:00`;
return v;
}
// ---------------- Cards ----------------
function pickCardFields(formData: FormData) {
return {
bankName: String(formData.get("bankName") ?? "").trim(),
cardName: String(formData.get("cardName") ?? "").trim(),
last4: String(formData.get("last4") ?? "").trim(),
creditLimit: String(formData.get("creditLimit") ?? "0"),
statementDay: String(formData.get("statementDay") ?? "1"),
dueDay: String(formData.get("dueDay") ?? "10"),
interestRate: String(formData.get("interestRate") ?? "4.25"),
bankAccountId: String(formData.get("bankAccountId") ?? ""),
notes: String(formData.get("notes") ?? "").trim(),
};
}
export async function createCreditCardAction(
_prev: CreditCardActionState,
formData: FormData,
): Promise<CreditCardActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = creditCardSchema.safeParse(pickCardFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.creditCards,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
...parsed.data,
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "credit_card",
entityId: row.$id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
return { ok: true };
}
export async function updateCreditCardAction(
_prev: CreditCardActionState,
formData: FormData,
): Promise<CreditCardActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = creditCardSchema.safeParse(pickCardFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.creditCards,
id,
)) as unknown as CreditCard;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.creditCards, id, parsed.data);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "credit_card",
entityId: id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
return { ok: true };
}
export async function archiveCreditCardAction(
formData: FormData,
): Promise<CreditCardActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.creditCards,
id,
)) as unknown as CreditCard;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const newArchivedState = !existing.archived;
await tablesDB.updateRow(DATABASE_ID, TABLES.creditCards, id, {
archived: newArchivedState,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "credit_card",
entityId: id,
changes: { archived: newArchivedState },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
return { ok: true };
}
export async function deleteCreditCardAction(
formData: FormData,
): Promise<CreditCardActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.creditCards,
id,
)) as unknown as CreditCard;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
// Cascade delete statements + their finance entries
const statements = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.creditCardStatements,
queries: [
Query.equal("tenantId", ctx.tenantId),
Query.equal("cardId", id),
Query.limit(500),
],
});
for (const s of statements.rows as unknown as CreditCardStatement[]) {
if (s.financeEntryId) {
try {
await tablesDB.deleteRow(DATABASE_ID, TABLES.financeEntries, s.financeEntryId);
} catch {
/* ignore */
}
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.creditCardStatements, s.$id);
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.creditCards, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "credit_card",
entityId: id,
changes: { bankName: existing.bankName, cardName: existing.cardName, statements: statements.rows.length },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
return { ok: true };
}
// ---------------- Statements ----------------
function pickStatementFields(formData: FormData) {
return {
cardId: String(formData.get("cardId") ?? ""),
period: String(formData.get("period") ?? "").trim(),
statementDate: String(formData.get("statementDate") ?? ""),
dueDate: String(formData.get("dueDate") ?? ""),
totalDebt: String(formData.get("totalDebt") ?? "0"),
minimumPayment: String(formData.get("minimumPayment") ?? "0"),
notes: String(formData.get("notes") ?? "").trim(),
};
}
function computeStatus(
totalDebt: number,
paidAmount: number,
dueDate: string,
): "pending" | "partial" | "paid" | "overdue" {
if (paidAmount >= totalDebt) return "paid";
const past = new Date(dueDate).getTime() < Date.now();
if (paidAmount > 0) return past ? "overdue" : "partial";
return past ? "overdue" : "pending";
}
export async function createStatementAction(
_prev: CreditCardActionState,
formData: FormData,
): Promise<CreditCardActionState> {
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = statementSchema.safeParse(pickStatementFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const card = (await tablesDB.getRow(
DATABASE_ID,
TABLES.creditCards,
parsed.data.cardId,
)) as unknown as CreditCard;
if (card.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const status = computeStatus(parsed.data.totalDebt, 0, parsed.data.dueDate);
const row = await tablesDB.createRow(
DATABASE_ID,
TABLES.creditCardStatements,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
cardId: parsed.data.cardId,
period: parsed.data.period,
statementDate: toIso(parsed.data.statementDate),
dueDate: toIso(parsed.data.dueDate),
totalDebt: parsed.data.totalDebt,
minimumPayment: parsed.data.minimumPayment,
paidAmount: 0,
status,
notes: parsed.data.notes,
},
teamRowPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "credit_card_statement",
entityId: row.$id,
changes: { cardId: parsed.data.cardId, period: parsed.data.period, totalDebt: parsed.data.totalDebt },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
return { ok: true };
}
export async function payStatementAction(formData: FormData): Promise<CreditCardActionState> {
const id = String(formData.get("id") ?? "");
const amountStr = String(formData.get("amount") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.creditCardStatements,
id,
)) as unknown as CreditCardStatement;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
const remaining = (existing.totalDebt ?? 0) - (existing.paidAmount ?? 0);
if (remaining <= 0) {
return { ok: false, error: "Bu ekstrenin bakiyesi kalmamış." };
}
const amount = amountStr
? Number(amountStr.replace(",", "."))
: remaining;
if (!Number.isFinite(amount) || amount <= 0) {
return { ok: false, error: "Tutar geçersiz.", fieldErrors: { amount: "Geçersiz" } };
}
const payAmount = Math.min(amount, remaining);
const card = (await tablesDB.getRow(
DATABASE_ID,
TABLES.creditCards,
existing.cardId,
)) as unknown as CreditCard;
const fe = await tablesDB.createRow(
DATABASE_ID,
TABLES.financeEntries,
ID.unique(),
{
tenantId: ctx.tenantId,
createdBy: ctx.user.id,
type: "expense",
amount: payAmount,
date: new Date().toISOString(),
description: `${card.bankName} ${card.cardName} ${existing.period} ekstre ödemesi`,
bankAccountId: card.bankAccountId,
},
teamRowPermissions(ctx.tenantId),
);
const newPaid = (existing.paidAmount ?? 0) + payAmount;
const newStatus = computeStatus(existing.totalDebt ?? 0, newPaid, existing.dueDate);
await tablesDB.updateRow(DATABASE_ID, TABLES.creditCardStatements, id, {
paidAmount: Number(newPaid.toFixed(2)),
status: newStatus,
financeEntryId: newStatus === "paid" ? fe.$id : existing.financeEntryId ?? fe.$id,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "credit_card_statement",
entityId: id,
changes: { paidAmount: newPaid, status: newStatus, payAmount },
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
revalidatePath("/finance");
return { ok: true };
}
export async function deleteStatementAction(
formData: FormData,
): Promise<CreditCardActionState> {
const id = String(formData.get("id") ?? "");
if (!id) return { ok: false, error: "ID eksik." };
let ctx;
try {
ctx = await requireTenant();
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const existing = (await tablesDB.getRow(
DATABASE_ID,
TABLES.creditCardStatements,
id,
)) as unknown as CreditCardStatement;
if (existing.tenantId !== ctx.tenantId) {
return { ok: false, error: "Erişim engellendi." };
}
if (existing.financeEntryId) {
try {
await tablesDB.deleteRow(
DATABASE_ID,
TABLES.financeEntries,
existing.financeEntryId,
);
} catch {
/* ignore */
}
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.creditCardStatements, id);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "credit_card_statement",
entityId: id,
});
} catch (e) {
return { ok: false, error: appwriteError(e) };
}
revalidatePath("/finance/cards");
revalidatePath("/finance");
return { ok: true };
}
+49
View File
@@ -0,0 +1,49 @@
import "server-only";
import { Query } from "node-appwrite";
import { createAdminClient } from "./server";
import {
DATABASE_ID,
TABLES,
type CreditCard,
type CreditCardStatement,
} from "./schema";
export async function listCreditCards(tenantId: string): Promise<CreditCard[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.creditCards,
queries: [
Query.equal("tenantId", tenantId),
Query.orderAsc("bankName"),
Query.limit(200),
],
});
return result.rows as unknown as CreditCard[];
} catch {
return [];
}
}
export async function listStatements(
tenantId: string,
): Promise<CreditCardStatement[]> {
try {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.creditCardStatements,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("statementDate"),
Query.limit(500),
],
});
return result.rows as unknown as CreditCardStatement[];
} catch {
return [];
}
}
+7
View File
@@ -0,0 +1,7 @@
export type CreditCardActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialCreditCardState: CreditCardActionState = { ok: false };
+34
View File
@@ -16,6 +16,8 @@ export const TABLES = {
bankAccounts: "bank_accounts", bankAccounts: "bank_accounts",
bankLoans: "bank_loans", bankLoans: "bank_loans",
loanInstallments: "loan_installments", loanInstallments: "loan_installments",
creditCards: "credit_cards",
creditCardStatements: "credit_card_statements",
} as const; } as const;
export type TableId = (typeof TABLES)[keyof typeof TABLES]; export type TableId = (typeof TABLES)[keyof typeof TABLES];
@@ -223,6 +225,38 @@ export interface LoanInstallment extends Row {
financeEntryId?: string; financeEntryId?: string;
} }
export interface CreditCard extends Row {
tenantId: string;
createdBy: string;
bankName: string;
cardName: string;
last4?: string;
creditLimit?: number;
statementDay?: number;
dueDay?: number;
interestRate?: number;
bankAccountId?: string;
archived?: boolean;
notes?: string;
}
export type StatementStatus = "pending" | "partial" | "paid" | "overdue";
export interface CreditCardStatement extends Row {
tenantId: string;
createdBy: string;
cardId: string;
period: string; // YYYY-MM
statementDate: string;
dueDate: string;
totalDebt: number;
minimumPayment?: number;
paidAmount?: number;
status?: StatementStatus;
financeEntryId?: string;
notes?: 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";
+73
View File
@@ -0,0 +1,73 @@
import { z } from "zod";
export const creditCardSchema = z.object({
bankName: z.string().trim().min(1, "Banka adı zorunlu.").max(100),
cardName: z.string().trim().min(1, "Kart adı zorunlu.").max(100),
last4: z
.string()
.trim()
.max(4)
.optional()
.transform((v) => (v ? v.replace(/\D/g, "").slice(-4) : undefined)),
creditLimit: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return 0;
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
return Number.isFinite(n) ? n : 0;
}),
statementDay: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return 1;
const n = typeof v === "string" ? parseInt(v, 10) : v;
return Number.isFinite(n) ? Math.min(28, Math.max(1, n)) : 1;
}),
dueDay: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return 10;
const n = typeof v === "string" ? parseInt(v, 10) : v;
return Number.isFinite(n) ? Math.min(28, Math.max(1, n)) : 10;
}),
interestRate: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return 4.25;
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
return Number.isFinite(n) ? n : 4.25;
}),
bankAccountId: z.string().optional().transform((v) => (v ? v : undefined)),
notes: z.string().trim().max(500).optional().transform((v) => (v ? v : undefined)),
});
export type CreditCardInput = z.infer<typeof creditCardSchema>;
export const statementSchema = z.object({
cardId: z.string().min(1, "Kart seçin."),
period: z
.string()
.regex(/^\d{4}-\d{2}$/, "Dönem YYYY-AA formatında olmalı.")
.max(7),
statementDate: z.string().min(1, "Hesap kesim tarihi zorunlu."),
dueDate: z.string().min(1, "Son ödeme tarihi zorunlu."),
totalDebt: z
.union([z.number(), z.string()])
.transform((v) => (typeof v === "string" ? Number(v.replace(",", ".")) : v))
.pipe(z.number().nonnegative("Negatif olamaz.")),
minimumPayment: z
.union([z.number(), z.string()])
.optional()
.transform((v) => {
if (v === undefined || v === "") return 0;
const n = typeof v === "string" ? Number(v.replace(",", ".")) : v;
return Number.isFinite(n) ? n : 0;
}),
notes: z.string().trim().max(500).optional().transform((v) => (v ? v : undefined)),
});
export type StatementInput = z.infer<typeof statementSchema>;