diff --git a/CHANGELOG.md b/CHANGELOG.md index 79f5c3a..aabcf87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +Faz 6 (alt fazlar halinde; `v0.6.0` Faz 6c sonunda). + +### Eklendi — Faz 6a: para, hesaplar ve kategoriler + +- `shared/money.ts`: Türkçe tutar ayrıştırma (`"1.250,50"` → 125050 kuruş; binlik/ondalık kuralları belgeli), `formatMoneyInput`, `sumMoney`, kuruş kaybı olmadan oransal bölme (`allocate`, `splitEvenly`). +- `shared/quantity.ts`: miktarlar birimin binde biri tamsayı (`1,5 kg` → 1500); ayrıştırma, biçimlendirme, kg↔g / lt↔ml dönüşümü. +- `shared/dates.ts`: İstanbul günü yardımcıları (gün başlangıcı, UTC → yerel gün). +- Hesaplar (`/hesaplar`): nakit, vadesiz banka (KMH limiti, aylık faiz, kesim günü), birikim, kredi kartı (limit, kesim günü, son ödeme kuralı, ödeme hesabı), yemek kartı (sağlayıcı); hesap sahibi kişi ve "yalnızca ben" (bakiyeyi yalnızca hesap sahibi görür, toplamlar hane ayarına uyar); renk/ikon, sıralama, arşiv. Varlık / kart borcu / net özet. +- Kategoriler (`/kategoriler`): gelir ve gider ağaçları (5 seviyeye kadar), ekleme, düzenleme, taşıma (alt ağaçla, transaction), sıralama, arşivleme (alt ağaçla); kardeşler arasında ad tekrarı engeli (Türkçe normalize). +- Varsayılan Türkçe kategori seti (44 kategori): yeni hanede aynı transaction'da, mevcut hanelere `002` migration'ıyla, idempotent (`defaultKey`). +- Kategori ağacı saf fonksiyonları (`buildTree`, `toOptions`, `checkMove`, `rollupTotals` …) ve birim testleri. +- Renk paleti (13 renk, açık/koyu tema değişkenleri) ve ikon kayıt tablosu; ikon/renk seçiciler, `MoneyInput`. +- Seed: varsayılan kategoriler ve 7 örnek hesap (KMH'li banka, kredi kartı, birikim, "yalnızca ben" yemek kartı). +- Denetim kaydı: hesap oluşturma/düzenleme/arşiv, kategori taşıma/arşiv. +- Entegrasyon testleri: kategori ve hesap izolasyonu, roller, gizli bakiye, kart kuralları, ağaç kuralları. + ## [0.5.0] - 2026-11-05 Faz 5: Haneler, hane içi roller ve veri izolasyonu. diff --git a/scripts/migrations/002-accounts-categories.ts b/scripts/migrations/002-accounts-categories.ts new file mode 100644 index 0000000..ef4487e --- /dev/null +++ b/scripts/migrations/002-accounts-categories.ts @@ -0,0 +1,19 @@ +import { Types } from "mongoose"; +import { installDefaultCategories } from "@/features/categories/service"; +import { AccountModel } from "@/models/account"; +import { CategoryModel } from "@/models/category"; +import { HouseholdModel } from "@/models/household"; +import type { Migration } from "./types"; + +/** Hesap ve kategori index'leri + mevcut hanelere varsayılan kategori seti (idempotent). */ +export const migration: Migration = { + name: "002-accounts-categories", + description: "accounts/categories index'leri ve mevcut hanelere varsayılan kategoriler", + async up() { + await AccountModel.createIndexes(); + await CategoryModel.createIndexes(); + for (const h of await HouseholdModel.find().select({ _id: 1, createdBy: 1 }).lean()) { + await installDefaultCategories(h._id as Types.ObjectId, h.createdBy as Types.ObjectId); + } + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 9c76e86..b92e442 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -1,10 +1,11 @@ import { getDb } from "@/lib/db/mongo"; import { connectMongoose } from "@/lib/db/mongoose"; import { migration as m001 } from "./001-initial-indexes"; +import { migration as m002 } from "./002-accounts-categories"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ -export const MIGRATIONS: Migration[] = [m001]; +export const MIGRATIONS: Migration[] = [m001, m002]; const COLLECTION = "migrations"; diff --git a/scripts/seed.ts b/scripts/seed.ts index 901e8c0..b45a035 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -11,8 +11,10 @@ import { closeSeedConnections, runSeed } from "./seed/run"; async function main() { const summary = await runSeed({ reset: process.argv.includes("--reset") }); - const { users, households, memberships } = summary.created; - console.log(`\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik.`); + const { users, households, memberships, categories, accounts } = summary.created; + console.log( + `\nOluşturulan: ${users} kullanıcı, ${households} hane, ${memberships} üyelik, ${categories} kategori, ${accounts} hesap.`, + ); // Hesap tablosu yalnızca geliştirme ortamında gösterilir. if (env().NODE_ENV !== "production" && summary.accounts.length) { diff --git a/scripts/seed/fixtures.ts b/scripts/seed/fixtures.ts index c46fa81..bb5d5df 100644 --- a/scripts/seed/fixtures.ts +++ b/scripts/seed/fixtures.ts @@ -1,4 +1,6 @@ import type { HouseholdRole } from "@/features/households/types"; +import type { AccountType, MealCardProvider } from "@/shared/accounts"; +import type { IconKey, Swatch } from "@/shared/appearance"; import type { SystemRole } from "@/shared/schemas/common"; /** Örnek (geliştirme) kullanıcıları. E-postası olmayanlar kullanıcı adıyla giriş yapar. */ @@ -56,3 +58,97 @@ export const SEED_HOUSEHOLDS: SeedHousehold[] = [ ], }, ]; + +/** Örnek hesaplar (tutarlar kuruş; kart borcu negatif). Açılış: 6 ay önce. */ +export type SeedAccount = { + household: string; + name: string; + type: AccountType; + institution?: string; + openingBalanceMinor: number; + color: Swatch; + icon: IconKey; + owner?: string; + isPrivate?: boolean; + mealCardProvider?: MealCardProvider; + overdraft?: { limitMinor: number; monthlyRateBp: number; statementDay: number }; + creditCard?: { + limitMinor: number; + statementDay: number; + dueDays: number; + paymentAccount: string; + }; +}; + +export const SEED_ACCOUNTS: SeedAccount[] = [ + { + household: "Çakır Ailesi", + name: "Cüzdan", + type: "cash", + openingBalanceMinor: 2_350_00, + color: "green", + icon: "wallet", + }, + { + household: "Çakır Ailesi", + name: "Ziraat Maaş", + type: "bank", + institution: "Ziraat Bankası", + openingBalanceMinor: 18_420_50, + color: "red", + icon: "landmark", + overdraft: { limitMinor: 10_000_00, monthlyRateBp: 425, statementDay: 1 }, + }, + { + household: "Çakır Ailesi", + name: "Garanti Bonus", + type: "credit_card", + institution: "Garanti BBVA", + openingBalanceMinor: -6_480_75, + color: "teal", + icon: "credit-card", + creditCard: { + limitMinor: 60_000_00, + statementDay: 29, + dueDays: 10, + paymentAccount: "Ziraat Maaş", + }, + }, + { + household: "Çakır Ailesi", + name: "Birikim (Vadeli)", + type: "savings", + institution: "Ziraat Bankası", + openingBalanceMinor: 45_000_00, + color: "indigo", + icon: "piggy-bank", + }, + { + household: "Çakır Ailesi", + name: "Ayşe — Pluxee", + type: "meal_card", + mealCardProvider: "pluxee", + openingBalanceMinor: 3_120_00, + color: "lime", + icon: "utensils", + owner: "ayse", + isPrivate: true, + }, + { + household: "Annemler", + name: "Nakit", + type: "cash", + openingBalanceMinor: 1_500_00, + color: "green", + icon: "wallet", + }, + { + household: "Annemler", + name: "Vakıfbank Emekli", + type: "bank", + institution: "VakıfBank", + openingBalanceMinor: 27_800_00, + color: "amber", + icon: "landmark", + }, +]; diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index 187daf5..bc0032c 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -24,18 +24,26 @@ describe("seed", () => { it("ilk çalıştırmada admin, örnek kullanıcılar, haneler ve üyelikleri oluşturur", async () => { const summary = await runSeed({ log: silent }); const memberCount = SEED_HOUSEHOLDS.reduce((n, h) => n + h.members.length, 0); - expect(summary.created).toEqual({ + expect(summary.created).toMatchObject({ users: SEED_USERS.length + 1, households: SEED_HOUSEHOLDS.length, memberships: memberCount, }); + expect(summary.created.categories).toBeGreaterThan(40); + expect(summary.created.accounts).toBe(7); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); }); it("idempotenttir: ikinci çalıştırma kopya oluşturmaz", async () => { const summary = await runSeed({ log: silent }); - expect(summary.created).toEqual({ users: 0, households: 0, memberships: 0 }); + expect(summary.created).toEqual({ + users: 0, + households: 0, + memberships: 0, + categories: 0, + accounts: 0, + }); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); }); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index da38c25..c8d1d00 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -3,11 +3,19 @@ import { getAuth } from "@/lib/auth"; import { getDb } from "@/lib/db/mongo"; import { connectMongoose } from "@/lib/db/mongoose"; import { env } from "@/lib/env"; +import { installDefaultCategories } from "@/features/categories/service"; +import { AccountModel } from "@/models/account"; import { HouseholdModel } from "@/models/household"; import { HouseholdMemberModel } from "@/models/household-member"; import { storedEmail } from "@/shared/account-email"; import { runMigrations } from "../migrations"; -import { ADMIN_USERNAME, SEED_HOUSEHOLDS, SEED_USERS, type SeedUser } from "./fixtures"; +import { + ADMIN_USERNAME, + SEED_ACCOUNTS, + SEED_HOUSEHOLDS, + SEED_USERS, + type SeedUser, +} from "./fixtures"; /** * İdempotent seed. Kullanıcılar Better Auth API'siyle oluşturulur (şifreler doğru hashlenir); @@ -26,12 +34,24 @@ export type SeedAccountRow = { }; export type SeedSummary = { - created: { users: number; households: number; memberships: number }; + created: { + users: number; + households: number; + memberships: number; + categories: number; + accounts: number; + }; accounts: SeedAccountRow[]; }; const AUTH_COLLECTIONS = ["user", "account", "session", "verification", "rateLimit"]; -const DOMAIN_COLLECTIONS = ["households", "householdMembers", "auditLogs"]; +const DOMAIN_COLLECTIONS = [ + "households", + "householdMembers", + "auditLogs", + "categories", + "accounts", +]; type AuthApi = ReturnType["api"]; @@ -73,7 +93,7 @@ export async function runSeed({ const db = getDb(); await connectMongoose(); const summary: SeedSummary = { - created: { users: 0, households: 0, memberships: 0 }, + created: { users: 0, households: 0, memberships: 0, categories: 0, accounts: 0 }, accounts: [], }; @@ -180,7 +200,53 @@ export async function runSeed({ } } - // 5) Geliştirme hesapları tablosu + // 5) Varsayılan kategoriler ve örnek hesaplar + const householdIds = new Map(); + for (const h of SEED_HOUSEHOLDS) { + const doc = await HouseholdModel.findOne({ name: h.name }).select({ _id: 1 }).lean(); + householdIds.set(h.name, doc!._id); + summary.created.categories += await installDefaultCategories(doc!._id, actor); + } + const openingDate = new Date(Date.now() - 183 * 24 * 60 * 60 * 1000); + for (const [order, a] of SEED_ACCOUNTS.entries()) { + const householdId = householdIds.get(a.household)!; + if (await AccountModel.exists({ householdId, name: a.name })) continue; + const payer = a.creditCard + ? await AccountModel.findOne({ householdId, name: a.creditCard.paymentAccount }) + .select({ _id: 1 }) + .lean() + : null; + await AccountModel.create({ + householdId, + name: a.name, + type: a.type, + institution: a.institution ?? "", + mealCardProvider: a.mealCardProvider ?? null, + openingBalanceMinor: a.openingBalanceMinor, + openingDate, + color: a.color, + icon: a.icon, + order, + ownerUserId: a.owner ? new Types.ObjectId(ids.get(a.owner)!) : null, + isPrivate: a.isPrivate ?? false, + overdraft: a.overdraft ? { enabled: true, ...a.overdraft } : null, + creditCard: a.creditCard + ? { + limitMinor: a.creditCard.limitMinor, + statementDay: a.creditCard.statementDay, + dueRule: { kind: "daysAfter", value: a.creditCard.dueDays }, + paymentAccountId: payer?._id ?? null, + } + : null, + createdBy: actor, + updatedBy: actor, + }); + summary.created.accounts++; + } + if (summary.created.categories) log(`✓ Varsayılan kategoriler: ${summary.created.categories}`); + if (summary.created.accounts) log(`✓ Örnek hesaplar: ${summary.created.accounts}`); + + // 6) Geliştirme hesapları tablosu const all = [ { username: ADMIN_USERNAME, name: "Yönetici", email: adminEmail, role: "admin" as const }, ...SEED_USERS, diff --git a/src/app/(app)/hesaplar/page.tsx b/src/app/(app)/hesaplar/page.tsx new file mode 100644 index 0000000..097a2aa --- /dev/null +++ b/src/app/(app)/hesaplar/page.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { AccountsView } from "@/features/accounts/components/accounts-view"; +import { listAccounts, listMemberOptions } from "@/features/accounts/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.accounts.title }; + +export default async function AccountsPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const [data, members] = await Promise.all([ + listAccounts(user.id, household.id, { includeArchived: true }), + listMemberOptions(user.id, household.id), + ]); + if (!data || !members) notFound(); + return ( + <> + + + + ); +} diff --git a/src/app/(app)/kategoriler/page.tsx b/src/app/(app)/kategoriler/page.tsx new file mode 100644 index 0000000..fff7401 --- /dev/null +++ b/src/app/(app)/kategoriler/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { CategoriesManager } from "@/features/categories/components/categories-manager"; +import { listCategories } from "@/features/categories/service"; +import { hasHouseholdRole } from "@/features/households/roles"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.categories.title }; + +export default async function CategoriesPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const categories = await listCategories(user.id, household.id, { includeArchived: true }); + if (!categories) notFound(); + return ( + <> + + + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index ae2ff36..da536eb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -96,6 +96,21 @@ --chart-4: var(--warning); --chart-5: oklch(0.65 0.08 230); --radius: 0.625rem; + + /* Kullanıcı verisi renkleri (kategori/hesap): anahtar → değişken (shared/appearance.ts) */ + --swatch-blue: oklch(0.6 0.15 258); + --swatch-sky: oklch(0.6 0.15 230); + --swatch-teal: oklch(0.6 0.15 185); + --swatch-green: oklch(0.6 0.15 150); + --swatch-lime: oklch(0.6 0.15 125); + --swatch-amber: oklch(0.6 0.15 80); + --swatch-orange: oklch(0.6 0.15 55); + --swatch-red: oklch(0.6 0.15 25); + --swatch-rose: oklch(0.6 0.15 10); + --swatch-pink: oklch(0.6 0.15 350); + --swatch-purple: oklch(0.6 0.15 305); + --swatch-indigo: oklch(0.6 0.15 275); + --swatch-slate: oklch(0.55 0.03 255); --sidebar: oklch(0.975 0.008 250); --sidebar-foreground: oklch(0.22 0.04 262); --sidebar-primary: oklch(0.49 0.17 258); @@ -140,6 +155,20 @@ --chart-3: var(--expense); --chart-4: var(--warning); --chart-5: oklch(0.7 0.07 230); + + --swatch-blue: oklch(0.74 0.13 258); + --swatch-sky: oklch(0.74 0.13 230); + --swatch-teal: oklch(0.74 0.13 185); + --swatch-green: oklch(0.74 0.13 150); + --swatch-lime: oklch(0.74 0.13 125); + --swatch-amber: oklch(0.74 0.13 80); + --swatch-orange: oklch(0.74 0.13 55); + --swatch-red: oklch(0.74 0.13 25); + --swatch-rose: oklch(0.74 0.13 10); + --swatch-pink: oklch(0.74 0.13 350); + --swatch-purple: oklch(0.74 0.13 305); + --swatch-indigo: oklch(0.74 0.13 275); + --swatch-slate: oklch(0.72 0.03 255); --sidebar: oklch(0.2 0.045 264); --sidebar-foreground: oklch(0.96 0.01 250); --sidebar-primary: oklch(0.76 0.12 245); diff --git a/src/components/appearance/app-icon.tsx b/src/components/appearance/app-icon.tsx new file mode 100644 index 0000000..161d641 --- /dev/null +++ b/src/components/appearance/app-icon.tsx @@ -0,0 +1,108 @@ +import { + Baby, + Banknote, + Book, + Briefcase, + Building, + Bus, + Car, + CircleEllipsis, + Coffee, + Coins, + CreditCard, + Droplet, + Dumbbell, + Film, + Flame, + Fuel, + Gamepad2, + Gift, + GraduationCap, + HeartPulse, + House, + Landmark, + Music, + PawPrint, + Percent, + PiggyBank, + Pill, + Plane, + Receipt, + Repeat, + Scissors, + Shirt, + ShoppingBasket, + ShoppingCart, + Smartphone, + Sofa, + Sparkles, + SprayCan, + Tag, + Train, + TrendingUp, + Undo2, + Utensils, + Wallet, + Wifi, + Wrench, + Zap, + type LucideIcon, + type LucideProps, +} from "lucide-react"; +import type { IconKey } from "@/shared/appearance"; + +/** Saklanan ikon anahtarı → lucide bileşeni (shared/appearance.ts ICON_KEYS ile birebir). */ +export const ICONS: Record = { + wallet: Wallet, + banknote: Banknote, + landmark: Landmark, + "piggy-bank": PiggyBank, + "credit-card": CreditCard, + utensils: Utensils, + "shopping-cart": ShoppingCart, + "shopping-basket": ShoppingBasket, + receipt: Receipt, + zap: Zap, + droplet: Droplet, + flame: Flame, + wifi: Wifi, + smartphone: Smartphone, + house: House, + building: Building, + car: Car, + fuel: Fuel, + bus: Bus, + train: Train, + "heart-pulse": HeartPulse, + pill: Pill, + "graduation-cap": GraduationCap, + book: Book, + shirt: Shirt, + gamepad: Gamepad2, + film: Film, + music: Music, + coffee: Coffee, + plane: Plane, + gift: Gift, + baby: Baby, + "paw-print": PawPrint, + wrench: Wrench, + sofa: Sofa, + repeat: Repeat, + briefcase: Briefcase, + "trending-up": TrendingUp, + coins: Coins, + percent: Percent, + undo: Undo2, + sparkles: Sparkles, + "spray-can": SprayCan, + scissors: Scissors, + dumbbell: Dumbbell, + tag: Tag, + "circle-ellipsis": CircleEllipsis, +}; + +export function AppIcon({ name, ...props }: { name: IconKey } & LucideProps) { + const Icon = ICONS[name] ?? Tag; + return ; +} diff --git a/src/components/appearance/pickers.tsx b/src/components/appearance/pickers.tsx new file mode 100644 index 0000000..2548d3f --- /dev/null +++ b/src/components/appearance/pickers.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ICON_KEYS, SWATCHES, type IconKey, type Swatch } from "@/shared/appearance"; +import { AppIcon } from "./app-icon"; +import { swatchStyle } from "./swatch"; +import { tr } from "@/i18n/tr"; + +/** Renk seçici (radio grubu, klavye ile gezilebilir). */ +export function SwatchPicker({ + value, + onChange, + labelledBy, +}: { + value: Swatch; + onChange: (v: Swatch) => void; + labelledBy: string; +}) { + return ( +
+ {SWATCHES.map((s) => ( + + ))} +
+ ); +} + +/** İkon seçici; seçili renkle önizler. */ +export function IconPicker({ + value, + color, + onChange, + labelledBy, +}: { + value: IconKey; + color: Swatch; + onChange: (v: IconKey) => void; + labelledBy: string; +}) { + return ( +
+ {ICON_KEYS.map((key) => ( + + ))} +
+ ); +} diff --git a/src/components/appearance/swatch.tsx b/src/components/appearance/swatch.tsx new file mode 100644 index 0000000..40bc4f4 --- /dev/null +++ b/src/components/appearance/swatch.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/lib/utils"; +import type { IconKey, Swatch } from "@/shared/appearance"; +import { AppIcon } from "./app-icon"; + +/** Renk anahtarını CSS değişkenine bağlar: --swatch-blue → --swatch (sınıflar bunu kullanır). */ +export const swatchStyle = (color: Swatch) => + ({ "--swatch": `var(--swatch-${color})` }) as React.CSSProperties; + +/** Renkli yuvarlak ikon rozeti (kategori/hesap). */ +export function IconBadge({ + icon, + color, + className, + size = "md", +}: { + icon: IconKey; + color: Swatch; + className?: string; + size?: "sm" | "md"; +}) { + return ( + + + + ); +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 62fbfd2..7243d26 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -8,6 +8,7 @@ import { Package, Settings, ShoppingCart, + Tags, Target, UserCog, Users, @@ -25,14 +26,16 @@ export type NavItem = { }; /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ -export const CURRENT_PHASE = 5; +/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ +export const CURRENT_PHASE = 6.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; export const MAIN_NAV: NavItem[] = [ { href: "/dashboard", label: tr.nav.dashboard, icon: LayoutDashboard, phase: 2 }, - { href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6 }, - { href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6 }, + { href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6.2 }, + { href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6.1 }, + { href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 }, { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7 }, { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 }, @@ -49,9 +52,11 @@ export const ADMIN_NAV: NavItem[] = [ ]; /** Mobil alt menü: ortada Hızlı ekle, sağda "Daha fazla" (yan menüyü açar). */ +const navByHref = (href: string) => MAIN_NAV.find((i) => i.href === href)!; + export const BOTTOM_NAV = { - left: [MAIN_NAV[0]!, MAIN_NAV[1]!], - right: [MAIN_NAV[6]!], + left: [navByHref("/dashboard"), navByHref("/islemler")], + right: [navByHref("/butce")], }; export function isActivePath(pathname: string, href: string) { diff --git a/src/components/money-input.tsx b/src/components/money-input.tsx new file mode 100644 index 0000000..9a73fd8 --- /dev/null +++ b/src/components/money-input.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@/components/ui/input-group"; +import { formatMoneyInput, parseMoney } from "@/shared/money"; + +/** + * Türkçe tutar girişi ("1.250,50"). Değer string olarak tutulur (zod sunucuda/istemcide ayrıştırır); + * alandan çıkınca geçerliyse Türkçe biçime getirilir. + */ +export function MoneyInput({ + value, + onChange, + onBlur, + allowNegative = false, + ...props +}: Omit, "value" | "onChange" | "type"> & { + value: string; + onChange: (value: string) => void; + allowNegative?: boolean; +}) { + return ( + + + onChange(e.target.value.replace(allowNegative ? /[^\d.,\s-−]/g : /[^\d.,\s]/g, "")) + } + onBlur={(e) => { + const minor = parseMoney(value); + if (minor !== null && value.trim() !== "") onChange(formatMoneyInput(minor)); + onBlur?.(e); + }} + /> + + ₺ + + + ); +} diff --git a/src/features/accounts/actions.ts b/src/features/accounts/actions.ts new file mode 100644 index 0000000..fa6137a --- /dev/null +++ b/src/features/accounts/actions.ts @@ -0,0 +1,75 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { accountFormSchema } from "@/shared/schemas/account"; +import { createAccount, reorderAccount, setAccountArchived, updateAccount } from "./service"; + +const PATH = "/hesaplar"; +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +/** zod hatalarını alan bazlı mesajlara çevirir (formda gösterilir). */ +function fieldErrors(error: z.ZodError) { + const out: Record = {}; + for (const issue of error.issues) { + const key = String(issue.path[0] ?? ""); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +export async function createAccountAction(input: unknown): Promise> { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = accountFormSchema.safeParse(input); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await createAccount(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +export async function updateAccountAction( + accountId: unknown, + input: unknown, +): Promise { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(accountId); + const parsed = accountFormSchema.safeParse(input); + if (!id.success) return fail(tr.common.notFound); + if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error)); + const result = await updateAccount(ctx.user.id, ctx.household.id, id.data, parsed.data); + if (result.ok) revalidatePath("/", "layout"); + return result; +} + +export async function setAccountArchivedAction( + accountId: unknown, + archived: unknown, +): Promise { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(accountId); + const a = z.boolean().safeParse(archived); + if (!id.success || !a.success) return fail(tr.common.genericError); + const result = await setAccountArchived(ctx.user.id, ctx.household.id, id.data, a.data); + if (result.ok) revalidatePath(PATH); + return result; +} + +export async function reorderAccountAction( + accountId: unknown, + direction: unknown, +): Promise { + const ctx = await getHouseholdActionContext("owner"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(accountId); + const dir = z.enum(["up", "down"]).safeParse(direction); + if (!id.success || !dir.success) return fail(tr.common.genericError); + const result = await reorderAccount(ctx.user.id, ctx.household.id, id.data, dir.data); + if (result.ok) revalidatePath(PATH); + return result; +} diff --git a/src/features/accounts/components/account-form-dialog.tsx b/src/features/accounts/components/account-form-dialog.tsx new file mode 100644 index 0000000..879f418 --- /dev/null +++ b/src/features/accounts/components/account-form-dialog.tsx @@ -0,0 +1,678 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { ChevronDown, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Controller, useForm, useWatch, type Control, type Resolver } from "react-hook-form"; +import { toast } from "sonner"; +import { IconPicker, SwatchPicker } from "@/components/appearance/pickers"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { tr } from "@/i18n/tr"; +import { + ACCOUNT_TYPES, + DUE_RULE_KINDS, + MEAL_CARD_PROVIDERS, + type AccountType, +} from "@/shared/accounts"; +import type { IconKey } from "@/shared/appearance"; +import { todayIstanbul } from "@/shared/dates"; +import { formatMoneyInput } from "@/shared/money"; +import { + accountFormSchema, + type AccountForm, + type AccountFormInput, +} from "@/shared/schemas/account"; +import { createAccountAction, updateAccountAction } from "../actions"; +import type { AccountView } from "../service"; + +const t = tr.accounts; +const f = t.fields; +const NONE = "__none__"; +const DAYS = Array.from({ length: 31 }, (_, i) => i + 1); + +const DEFAULT_ICON: Record = { + cash: "wallet", + bank: "landmark", + savings: "piggy-bank", + credit_card: "credit-card", + meal_card: "utensils", +}; + +type FormValues = { + name: string; + type: AccountType; + institution: string; + mealCardProvider: AccountFormInput["mealCardProvider"]; + openingBalance: string; + openingDate: string; + color: AccountFormInput["color"]; + icon: AccountFormInput["icon"]; + ownerUserId: string; + isPrivate: boolean; + overdraftEnabled: boolean; + overdraftLimit: string; + overdraftMonthlyRate: string; + overdraftStatementDay: string; + cardLimit: string; + cardStatementDay: string; + cardDueKind: AccountFormInput["cardDueKind"]; + cardDueValue: string; + cardPaymentAccountId: string; +}; + +const money = (minor: number | null | undefined) => (minor ? formatMoneyInput(minor) : ""); + +function toValues(a?: AccountView): FormValues { + if (!a) { + return { + name: "", + type: "bank", + institution: "", + mealCardProvider: null, + openingBalance: "", + openingDate: todayIstanbul(), + color: "blue", + icon: "landmark", + ownerUserId: "", + isPrivate: false, + overdraftEnabled: false, + overdraftLimit: "", + overdraftMonthlyRate: "", + overdraftStatementDay: "", + cardLimit: "", + cardStatementDay: "1", + cardDueKind: "daysAfter", + cardDueValue: "10", + cardPaymentAccountId: "", + }; + } + const isCard = a.type === "credit_card"; + return { + name: a.name, + type: a.type, + institution: a.institution, + mealCardProvider: a.mealCardProvider, + openingBalance: + a.openingBalanceMinor === null + ? "" + : money(isCard ? Math.abs(a.openingBalanceMinor) : a.openingBalanceMinor), + openingDate: a.openingDate, + color: a.color, + icon: a.icon, + ownerUserId: a.ownerUserId ?? "", + isPrivate: a.isPrivate, + overdraftEnabled: Boolean(a.overdraft), + overdraftLimit: money(a.overdraft?.limitMinor), + overdraftMonthlyRate: a.overdraft?.monthlyRateBp + ? formatMoneyInput(a.overdraft.monthlyRateBp) + : "", + overdraftStatementDay: a.overdraft?.statementDay ? String(a.overdraft.statementDay) : "", + cardLimit: money(a.creditCard?.limitMinor), + cardStatementDay: String(a.creditCard?.statementDay ?? 1), + cardDueKind: a.creditCard?.dueRule.kind ?? "daysAfter", + cardDueValue: String(a.creditCard?.dueRule.value ?? 10), + cardPaymentAccountId: a.creditCard?.paymentAccountId ?? "", + }; +} + +function DaySelect({ + id, + value, + onChange, + max = 31, + allowEmpty, +}: { + id: string; + value: string; + onChange: (v: string) => void; + max?: number; + allowEmpty?: boolean; +}) { + return ( + + ); +} + +function MoneyField({ + control, + name, + label, + hint, + allowNegative, + disabled, +}: { + control: Control; + name: "openingBalance" | "overdraftLimit" | "cardLimit"; + label: string; + hint?: string; + allowNegative?: boolean; + disabled?: boolean; +}) { + return ( + ( + + {label} + + {hint ? {hint} : null} + {fieldState.invalid ? : null} + + )} + /> + ); +} + +export function AccountFormDialog({ + open, + account, + members, + paymentAccounts, + onOpenChange, +}: { + open: boolean; + account?: AccountView; + members: { id: string; name: string }[]; + paymentAccounts: { id: string; name: string }[]; + onOpenChange: (open: boolean) => void; +}) { + const isEdit = Boolean(account); + const balanceHidden = isEdit && account!.openingBalanceMinor === null; + const form = useForm({ + // FormValues, şemanın giriş şekliyle birebir (sayısal alanlar string olarak girilir, zod çevirir). + resolver: zodResolver(accountFormSchema) as unknown as Resolver< + FormValues, + unknown, + AccountForm + >, + defaultValues: toValues(), + }); + const [advancedOpen, setAdvancedOpen] = useState(false); + + useEffect(() => { + if (open) form.reset(toValues(account)); + }, [open, account, form]); + + function handleOpenChange(next: boolean) { + if (!next) setAdvancedOpen(false); + onOpenChange(next); + } + + const type = useWatch({ control: form.control, name: "type" }); + const color = useWatch({ control: form.control, name: "color" }); + const overdraftEnabled = useWatch({ control: form.control, name: "overdraftEnabled" }); + const dueKind = useWatch({ control: form.control, name: "cardDueKind" }); + const isCard = type === "credit_card"; + + /** Ham form değerleri gönderilir; sunucu aynı şemayla ayrıştırır. */ + async function onSubmit() { + const values = form.getValues(); + // Gizli bakiyede sunucu mevcut değeri korur; doğrulamadan geçmesi için 0 gönderilir. + const payload = balanceHidden ? { ...values, openingBalance: "0" } : values; + const result = account + ? await updateAccountAction(account.id, payload) + : await createAccountAction(payload); + if (result.ok) { + toast.success(account ? t.updated(values.name) : t.created(values.name)); + handleOpenChange(false); + return; + } + let advancedError = false; + for (const [name, message] of Object.entries(result.fieldErrors ?? {})) { + if (!message) continue; + form.setError(name as keyof FormValues, { message }); + if (/^(overdraft|ownerUserId|isPrivate)/.test(name)) advancedError = true; + } + if (advancedError) setAdvancedOpen(true); + if (!result.fieldErrors) form.setError("root", { message: result.error }); + } + + return ( + + + + + } + > +
+ + ( + + {f.type} + + {isEdit ? {f.typeLockedHint} : null} + + )} + /> + ( + + {f.name} + + {fieldState.invalid ? : null} + + )} + /> + {type === "meal_card" ? ( + ( + + {f.provider} + + {fieldState.invalid ? : null} + + )} + /> + ) : type !== "cash" ? ( + ( + + {f.institution} + + + )} + /> + ) : null} +
+ + ( + + {f.openingDate} + + {fieldState.invalid ? : null} + + )} + /> +
+ + {isCard ? ( +
+ {f.card} + + + ( + + {f.cardStatementDay} + + {f.cardStatementDayHint} + + )} + /> +
+ ( + + {f.cardDueKind} + + + )} + /> + ( + + + {dueKind === "daysAfter" ? f.cardDueValueDays : f.cardDueValueDay} + + + {fieldState.invalid ? : null} + + )} + /> +
+ ( + + {f.cardPaymentAccount} + + {fieldState.invalid ? : null} + + )} + /> +
+
+ ) : null} + + + + + + + + {type === "bank" ? ( +
+ {f.overdraft} + + ( + + field.onChange(v === true)} + /> + + {f.overdraftEnabled} + + + )} + /> + {overdraftEnabled ? ( + <> + +
+ ( + + {f.overdraftRate} + + {fieldState.invalid ? ( + + ) : null} + + )} + /> + ( + + + {f.overdraftStatementDay} + + + + )} + /> +
+ + ) : null} +
+
+ ) : null} + + ( + + {f.owner} + + {fieldState.invalid ? : null} + + )} + /> + ( + + field.onChange(v === true)} + /> + + {f.isPrivate} + + + )} + /> + ( + + {tr.categories.color} + + + )} + /> + ( + + {tr.categories.icon} + + + )} + /> +
+
+
+ {form.formState.errors.root ? ( + {form.formState.errors.root.message} + ) : null} +
+
+
+ ); +} diff --git a/src/features/accounts/components/accounts-view.tsx b/src/features/accounts/components/accounts-view.tsx new file mode 100644 index 0000000..eec7c68 --- /dev/null +++ b/src/features/accounts/components/accounts-view.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { + Archive, + ArchiveRestore, + ArrowDown, + ArrowUp, + ChevronDown, + EyeOff, + MoreHorizontal, + Pencil, + Plus, +} from "lucide-react"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { IconBadge } from "@/components/appearance/swatch"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { ACCOUNT_TYPES, isLiabilityType } from "@/shared/accounts"; +import { formatMoney } from "@/shared/format"; +import { reorderAccountAction, setAccountArchivedAction } from "../actions"; +import type { AccountTotals, AccountView } from "../service"; +import { AccountFormDialog } from "./account-form-dialog"; + +const t = tr.accounts; + +function BalanceText({ account }: { account: AccountView }) { + if (account.balanceMinor === null) { + return ( + + + {t.hidden} + + ); + } + const liability = isLiabilityType(account.type); + const value = account.balanceMinor; + return ( + + {liability ? {t.debt} : null} + + {formatMoney(liability ? Math.abs(value) : value)} + + + ); +} + +export function AccountsView({ + accounts, + totals, + members, + canEdit, +}: { + accounts: AccountView[]; + totals: AccountTotals; + members: { id: string; name: string }[]; + canEdit: boolean; +}) { + const [dialog, setDialog] = useState<{ account?: AccountView } | null>(null); + const [archiving, setArchiving] = useState(null); + const [pending, startTransition] = useTransition(); + const active = accounts.filter((a) => !a.archived); + const archived = accounts.filter((a) => a.archived); + const memberName = (id: string | null) => members.find((m) => m.id === id)?.name; + const paymentAccounts = active + .filter((a) => ["bank", "cash", "savings"].includes(a.type)) + .map((a) => ({ id: a.id, name: a.name })); + + function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) { + startTransition(async () => { + const r = await promise; + if (r.ok) { + if (success) toast.success(success); + } else toast.error(r.error ?? tr.common.genericError); + }); + } + + function Row({ a, index }: { a: AccountView; index: number }) { + const owner = memberName(a.ownerUserId); + return ( +
  • + +
    +

    {a.name}

    +
    + {a.type === "meal_card" && a.mealCardProvider ? ( + {t.providers[a.mealCardProvider]} + ) : null} + {a.institution ? {a.institution} : null} + {owner ? · {owner} : null} + {a.isPrivate ? {t.privateBadge} : null} + {a.overdraft ? ( + + {t.overdraftBadge(formatMoney(a.overdraft.limitMinor))} + + ) : null} + {a.creditCard ? ( + <> + + {t.limitLabel(formatMoney(a.creditCard.limitMinor))} + + {t.statementLabel(a.creditCard.statementDay)} + + ) : null} +
    +
    + + {canEdit ? ( + + + + + + {a.archived ? ( + + run(setAccountArchivedAction(a.id, false), t.unarchivedToast(a.name)) + } + > + + {t.unarchive} + + ) : ( + <> + setDialog({ account: a })}> + + {tr.common.edit} + + run(reorderAccountAction(a.id, "up"))} + > + + {tr.categories.moveUp} + + run(reorderAccountAction(a.id, "down"))} + > + + {tr.categories.moveDown} + + + setArchiving(a)}> + + {t.archive} + + + )} + + + ) : null} +
  • + ); + } + + const summary = [ + { label: t.summary.assets, value: totals.assetsMinor, tone: "text-income" }, + { + label: t.summary.cardDebt, + value: Math.abs(totals.cardDebtMinor), + tone: totals.cardDebtMinor ? "text-expense" : "", + }, + { + label: t.summary.net, + value: totals.netMinor, + tone: totals.netMinor < 0 ? "text-expense" : "", + }, + ]; + + return ( +
    +
    + {summary.map((s) => ( + + + {s.label} + + {formatMoney(s.value)} + + + + ))} +
    + {totals.includesHidden || totals.excludesHidden ? ( +

    + {totals.excludesHidden ? t.summary.excludesHidden : t.summary.includesHidden} +

    + ) : null} + +
    + {!canEdit ?

    {t.ownerOnly}

    : null} + {canEdit ? ( + + ) : null} +
    + + {active.length === 0 ? ( +
    +

    {t.empty}

    + {canEdit ?

    {t.emptyOwnerHint}

    : null} +
    + ) : ( + ACCOUNT_TYPES.map((type) => { + const group = active.filter((a) => a.type === type); + if (!group.length) return null; + return ( +
    +

    + {t.typeGroups[type]} +

    +
      + {group.map((a) => ( + + ))} +
    +
    + ); + }) + )} + + {archived.length ? ( + + + + + +
      + {archived.map((a) => ( + + ))} +
    +
    +
    + ) : null} + + !o && setDialog(null)} + /> + + !o && setArchiving(null)}> + + + {t.archiveTitle} + + {archiving ? t.archiveDescription(archiving.name) : null} + + + + {tr.common.cancel} + + + + +
    + ); +} diff --git a/src/features/accounts/service.int.test.ts b/src/features/accounts/service.int.test.ts new file mode 100644 index 0000000..1db1033 --- /dev/null +++ b/src/features/accounts/service.int.test.ts @@ -0,0 +1,156 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { AccountModel } from "@/models/account"; +import { HouseholdModel } from "@/models/household"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { createAccount, listAccounts, setAccountArchived, updateAccount } from "./service"; + +let ids: Awaited>; +let cakir = ""; +let annemler = ""; + +beforeAll(async () => { + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + annemler = ids.household("Annemler"); +}); +afterAll(teardownDb); + +const base = { + name: "Test Hesap", + type: "bank" as const, + institution: "", + mealCardProvider: null, + openingBalance: 100_00, + openingDate: "2026-05-01", + color: "blue" as const, + icon: "landmark" as const, + ownerUserId: null, + isPrivate: false, + overdraftEnabled: false, + overdraftLimit: 0, + overdraftMonthlyRate: null, + overdraftStatementDay: null, + cardLimit: 0, + cardStatementDay: 1, + cardDueKind: "daysAfter" as const, + cardDueValue: 10, + cardPaymentAccountId: null, +}; + +const accountId = (name: string, householdId = cakir) => + AccountModel.findOne({ householdId, name }) + .lean() + .then((a) => String(a!._id)); + +describe("roller ve izolasyon", () => { + it("yalnızca sahip hesap oluşturur", async () => { + expect((await createAccount(ids.user("emre"), cakir, base)).ok).toBe(false); + expect((await createAccount(ids.user("zeynep"), cakir, base)).ok).toBe(false); + expect((await createAccount(ids.user("fatma"), cakir, base)).ok).toBe(false); + expect((await createAccount(ids.user("ayse"), cakir, base)).ok).toBe(true); + }); + + it("başka hanenin hesaplarını göremez ve değiştiremez", async () => { + expect(await listAccounts(ids.user("fatma"), cakir)).toBeNull(); + const cuzdan = await accountId("Cüzdan"); + expect( + ( + await updateAccount(ids.user("fatma"), annemler, cuzdan, { + ...base, + type: "cash", + name: "X", + }) + ).ok, + ).toBe(false); + expect((await setAccountArchived(ids.user("fatma"), annemler, cuzdan, true)).ok).toBe(false); + const own = (await listAccounts(ids.user("fatma"), annemler))!.accounts.map((a) => a.name); + expect(own.sort()).toEqual(["Nakit", "Vakıfbank Emekli"]); + }); + + it("başka hanenin hesabı kart ödeme hesabı veya hesap sahibi başka hanenin üyesi olamaz", async () => { + const vakif = await accountId("Vakıfbank Emekli", annemler); + const card = { + ...base, + type: "credit_card" as const, + name: "Sızma Kart", + cardPaymentAccountId: vakif, + }; + expect((await createAccount(ids.user("ayse"), cakir, card)).ok).toBe(false); + expect( + (await createAccount(ids.user("ayse"), cakir, { ...base, ownerUserId: ids.user("fatma") })) + .ok, + ).toBe(false); + }); +}); + +describe("“yalnızca ben” hesabı", () => { + it("bakiyeyi yalnızca hesap sahibi görür (hane sahibi dahil diğerleri göremez)", async () => { + const find = async (user: string) => + (await listAccounts(ids.user(user), cakir))!.accounts.find( + (a) => a.name === "Ayşe — Pluxee", + )!; + expect((await find("ayse")).balanceMinor).toBe(3_120_00); + expect((await find("admin")).balanceMinor).toBeNull(); + expect((await find("emre")).balanceMinor).toBeNull(); + expect((await find("emre")).openingBalanceMinor).toBeNull(); + }); + + it("toplamlar hane ayarına uyar", async () => { + const withHidden = (await listAccounts(ids.user("emre"), cakir))!.totals; + expect(withHidden.includesHidden).toBe(true); + await HouseholdModel.updateOne( + { _id: cakir }, + { $set: { "settings.includePrivateAccountsInTotals": false } }, + ); + const without = (await listAccounts(ids.user("emre"), cakir))!.totals; + expect(without.excludesHidden).toBe(true); + expect(withHidden.assetsMinor - without.assetsMinor).toBe(3_120_00); + // Hesap sahibi kendi hesabını her zaman toplamda görür. + const ayse = (await listAccounts(ids.user("ayse"), cakir))!.totals; + expect(ayse.assetsMinor).toBe(withHidden.assetsMinor); + await HouseholdModel.updateOne( + { _id: cakir }, + { $set: { "settings.includePrivateAccountsInTotals": true } }, + ); + }); + + it("bakiyeyi göremeyen sahip düzenlediğinde gizli bakiye korunur", async () => { + const id = await accountId("Ayşe — Pluxee"); + const r = await updateAccount(ids.user("admin"), cakir, id, { + ...base, + type: "meal_card", + mealCardProvider: "pluxee", + name: "Ayşe — Pluxee", + openingBalance: 0, + ownerUserId: ids.user("ayse"), + isPrivate: true, + }); + expect(r.ok).toBe(true); + expect((await AccountModel.findById(id).lean())?.openingBalanceMinor).toBe(3_120_00); + }); +}); + +describe("kart ve tür kuralları", () => { + it("kart açılış borcu negatif saklanır; tür değiştirilemez", async () => { + const payer = await accountId("Ziraat Maaş"); + const r = await createAccount(ids.user("ayse"), cakir, { + ...base, + type: "credit_card", + name: "Yeni Kart", + openingBalance: 1_500_00, + cardLimit: 20_000_00, + cardStatementDay: 31, + cardPaymentAccountId: payer, + }); + expect(r.ok).toBe(true); + const card = await AccountModel.findById(r.ok ? r.data.id : "").lean(); + expect(card?.openingBalanceMinor).toBe(-1_500_00); + expect(card?.creditCard?.statementDay).toBe(31); + expect(card?.creditCard?.paymentAccountId?.toString()).toBe(payer); + const change = await updateAccount(ids.user("ayse"), cakir, String(card!._id), { + ...base, + name: "Yeni Kart", + }); + expect(change.ok).toBe(false); + }); +}); diff --git a/src/features/accounts/service.ts b/src/features/accounts/service.ts new file mode 100644 index 0000000..4833842 --- /dev/null +++ b/src/features/accounts/service.ts @@ -0,0 +1,376 @@ +import "server-only"; +import { Types } from "mongoose"; +import { getHouseholdDetail, resolveMembership } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { diffFields, writeAudit } from "@/lib/audit"; +import { toObjectId } from "@/lib/object-id"; +import { AccountModel } from "@/models/account"; +import { HouseholdModel } from "@/models/household"; +import { HouseholdMemberModel } from "@/models/household-member"; +import { + isLiabilityType, + type AccountType, + type DueRuleKind, + type MealCardProvider, +} from "@/shared/accounts"; +import type { IconKey, Swatch } from "@/shared/appearance"; +import { istanbulDayStart, toIstanbulYmd } from "@/shared/dates"; +import type { AccountForm } from "@/shared/schemas/account"; +import { compareTr } from "@/shared/search"; + +/** + * Hesap servisi. Okuma: viewer; oluşturma/düzenleme/arşiv/sıralama: owner (Bölüm 5). + * "Yalnızca ben" hesaplarında bakiye yalnızca hesap sahibi kişiye görünür. + * Bakiye işareti: varlık pozitif, kredi kartı borcu negatif. + */ + +const t = tr.accounts; + +export type AccountView = { + id: string; + name: string; + type: AccountType; + institution: string; + mealCardProvider: MealCardProvider | null; + color: Swatch; + icon: IconKey; + order: number; + archived: boolean; + isPrivate: boolean; + ownerUserId: string | null; + /** Bu kullanıcıya gizliyse null. */ + balanceMinor: number | null; + openingBalanceMinor: number | null; + openingDate: string; + overdraft: { + enabled: boolean; + limitMinor: number; + monthlyRateBp: number | null; + statementDay: number | null; + } | null; + creditCard: { + limitMinor: number; + statementDay: number; + dueRule: { kind: DueRuleKind; value: number }; + paymentAccountId: string | null; + } | null; +}; + +export type AccountTotals = { + /** Nakit + banka + birikim + yemek kartı (pozitif bakiyeler). */ + assetsMinor: number; + /** Kredi kartı borçları (negatif). */ + cardDebtMinor: number; + netMinor: number; + /** Toplamlarda bu kullanıcının bakiyesini göremediği hesap var mı? */ + includesHidden: boolean; + /** Gizli hesaplar hane ayarı gereği toplamlardan çıkarıldı mı? */ + excludesHidden: boolean; +}; + +type Lean = { + _id: Types.ObjectId; + name: string; + type: AccountType; + institution: string; + mealCardProvider: MealCardProvider | null; + color: Swatch; + icon: IconKey; + order: number; + archivedAt: Date | null; + isPrivate: boolean; + ownerUserId: Types.ObjectId | null; + openingBalanceMinor: number; + openingDate: Date; + overdraft: AccountView["overdraft"]; + creditCard: + | (Omit, "paymentAccountId"> & { + paymentAccountId: Types.ObjectId | null; + }) + | null; +}; + +const canSeeBalance = (a: Lean, viewerId: string) => + !a.isPrivate || String(a.ownerUserId) === viewerId; + +/** FAZ 6b: açılış bakiyesi + işlemler. Şimdilik açılış bakiyesi. */ +function computeBalance(a: Lean): number { + return a.openingBalanceMinor; +} + +function toView(a: Lean, viewerId: string): AccountView { + const visible = canSeeBalance(a, viewerId); + return { + id: String(a._id), + name: a.name, + type: a.type, + institution: a.institution ?? "", + mealCardProvider: a.mealCardProvider ?? null, + color: a.color, + icon: a.icon, + order: a.order, + archived: Boolean(a.archivedAt), + isPrivate: a.isPrivate, + ownerUserId: a.ownerUserId ? String(a.ownerUserId) : null, + balanceMinor: visible ? computeBalance(a) : null, + openingBalanceMinor: visible ? a.openingBalanceMinor : null, + openingDate: toIstanbulYmd(a.openingDate), + overdraft: a.overdraft?.enabled ? a.overdraft : null, + creditCard: a.creditCard + ? { + ...a.creditCard, + paymentAccountId: a.creditCard.paymentAccountId + ? String(a.creditCard.paymentAccountId) + : null, + } + : null, + }; +} + +export async function listAccounts( + userId: string, + householdId: string, + opts: { includeArchived?: boolean } = {}, +): Promise<{ accounts: AccountView[]; totals: AccountTotals } | null> { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const filter: Record = { householdId: m.householdId }; + if (!opts.includeArchived) filter.archivedAt = null; + const rows = (await AccountModel.find(filter).lean()).sort( + (a, b) => a.order - b.order || compareTr(a.name, b.name), + ); + const household = await HouseholdModel.findById(m.householdId).select({ settings: 1 }).lean(); + const includePrivate = household?.settings?.includePrivateAccountsInTotals ?? true; + + const totals: AccountTotals = { + assetsMinor: 0, + cardDebtMinor: 0, + netMinor: 0, + includesHidden: false, + excludesHidden: false, + }; + for (const a of rows) { + if (a.archivedAt) continue; + const visible = canSeeBalance(a, userId); + if (!visible && !includePrivate) { + totals.excludesHidden = true; + continue; + } + if (!visible) totals.includesHidden = true; + const balance = computeBalance(a); + if (isLiabilityType(a.type)) totals.cardDebtMinor += balance; + else totals.assetsMinor += balance; + } + totals.netMinor = totals.assetsMinor + totals.cardDebtMinor; + return { accounts: rows.map((a) => toView(a, userId)), totals }; +} + +/** Form → belge alanları. Kart için açılış borcu negatif saklanır. */ +async function toDocFields( + householdId: Types.ObjectId, + input: AccountForm, + selfId?: Types.ObjectId, +): Promise>> { + const openingDate = istanbulDayStart(input.openingDate); + if (!openingDate) return fail(tr.validation.date, { openingDate: tr.validation.date }); + + let ownerUserId: Types.ObjectId | null = null; + if (input.ownerUserId) { + ownerUserId = toObjectId(input.ownerUserId); + const isMember = + ownerUserId && (await HouseholdMemberModel.exists({ householdId, userId: ownerUserId })); + if (!isMember) return fail(t.ownerNotMember, { ownerUserId: t.ownerNotMember }); + } + + let paymentAccountId: Types.ObjectId | null = null; + if (input.type === "credit_card" && input.cardPaymentAccountId) { + paymentAccountId = toObjectId(input.cardPaymentAccountId); + const payer = + paymentAccountId && + !paymentAccountId.equals(selfId ?? new Types.ObjectId()) && + (await AccountModel.exists({ + _id: paymentAccountId, + householdId, + archivedAt: null, + type: { $in: ["bank", "cash", "savings"] }, + })); + if (!payer) + return fail(t.invalidPaymentAccount, { cardPaymentAccountId: t.invalidPaymentAccount }); + } + + const isCard = input.type === "credit_card"; + return ok({ + name: input.name, + institution: input.institution, + mealCardProvider: input.type === "meal_card" ? input.mealCardProvider : null, + openingBalanceMinor: isCard ? -Math.abs(input.openingBalance) : input.openingBalance, + openingDate, + color: input.color, + icon: input.icon, + ownerUserId, + isPrivate: input.isPrivate, + overdraft: + input.type === "bank" && input.overdraftEnabled + ? { + enabled: true, + limitMinor: input.overdraftLimit, + monthlyRateBp: input.overdraftMonthlyRate, + statementDay: input.overdraftStatementDay, + } + : null, + creditCard: isCard + ? { + limitMinor: input.cardLimit, + statementDay: input.cardStatementDay, + dueRule: { kind: input.cardDueKind, value: input.cardDueValue }, + paymentAccountId, + } + : null, + }); +} + +export async function createAccount( + actorId: string, + householdId: string, + input: AccountForm, +): Promise> { + const m = await resolveMembership(actorId, householdId, "owner"); + if (!m) return fail(tr.common.unauthorized); + const fields = await toDocFields(m.householdId, input); + if (!fields.ok) return fields; + const last = await AccountModel.findOne({ householdId: m.householdId }) + .sort({ order: -1 }) + .select({ order: 1 }) + .lean(); + const actor = new Types.ObjectId(actorId); + const doc = await AccountModel.create({ + ...fields.data, + householdId: m.householdId, + type: input.type, + order: (last?.order ?? -1) + 1, + createdBy: actor, + updatedBy: actor, + }); + await writeAudit({ + action: "account.create", + actorId: actor, + householdId: m.householdId, + targetType: "account", + targetId: String(doc._id), + diff: { name: input.name, type: input.type }, + }); + return ok({ id: String(doc._id) }); +} + +export async function updateAccount( + actorId: string, + householdId: string, + accountId: string, + input: AccountForm, +): Promise { + const m = await resolveMembership(actorId, householdId, "owner"); + if (!m) return fail(tr.common.unauthorized); + const id = toObjectId(accountId); + const current = id + ? await AccountModel.findOne({ _id: id, householdId: m.householdId }).lean() + : null; + if (!current) return fail(tr.common.notFound); + // Tür değişikliği desteklenmez (işlemler ve kart dönemleri türe bağlıdır). + if (current.type !== input.type) return fail(t.typeLocked, { type: t.typeLocked }); + const fields = await toDocFields(m.householdId, input, current._id); + if (!fields.ok) return fields; + // Bakiyesini göremediği gizli hesapta açılış bakiyesi korunur (ne görünür ne değişir). + if (!canSeeBalance(current, actorId)) delete fields.data.openingBalanceMinor; + + const actor = new Types.ObjectId(actorId); + await AccountModel.updateOne( + { _id: current._id, householdId: m.householdId }, + { $set: { ...fields.data, updatedBy: actor } }, + ); + await writeAudit({ + action: "account.update", + actorId: actor, + householdId: m.householdId, + targetType: "account", + targetId: accountId, + // Gizli hesabın bakiyesi denetim kaydına yazılmaz. + diff: diffFields( + { + name: current.name, + institution: current.institution, + isPrivate: current.isPrivate, + ownerUserId: String(current.ownerUserId ?? ""), + }, + { + name: input.name, + institution: input.institution, + isPrivate: input.isPrivate, + ownerUserId: input.ownerUserId ?? "", + }, + ), + }); + return ok(undefined); +} + +export async function setAccountArchived( + actorId: string, + householdId: string, + accountId: string, + archived: boolean, +): Promise { + const m = await resolveMembership(actorId, householdId, "owner"); + if (!m) return fail(tr.common.unauthorized); + const id = toObjectId(accountId); + const current = id + ? await AccountModel.findOne({ _id: id, householdId: m.householdId }).lean() + : null; + if (!current) return fail(tr.common.notFound); + const actor = new Types.ObjectId(actorId); + await AccountModel.updateOne( + { _id: current._id, householdId: m.householdId }, + { $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } }, + ); + await writeAudit({ + action: archived ? "account.archive" : "account.unarchive", + actorId: actor, + householdId: m.householdId, + targetType: "account", + targetId: accountId, + diff: { name: current.name }, + }); + return ok(undefined); +} + +export async function reorderAccount( + actorId: string, + householdId: string, + accountId: string, + direction: "up" | "down", +): Promise { + const m = await resolveMembership(actorId, householdId, "owner"); + if (!m) return fail(tr.common.unauthorized); + const list = ( + await AccountModel.find({ householdId: m.householdId, archivedAt: null }).lean() + ).sort((a, b) => a.order - b.order || compareTr(a.name, b.name)); + const i = list.findIndex((a) => String(a._id) === accountId); + const j = direction === "up" ? i - 1 : i + 1; + if (i < 0 || j < 0 || j >= list.length) return ok(undefined); + [list[i], list[j]] = [list[j]!, list[i]!]; + await AccountModel.bulkWrite( + list.map((a, order) => ({ + updateOne: { + filter: { _id: a._id, householdId: m.householdId }, + update: { $set: { order } }, + }, + })), + ); + return ok(undefined); +} + +/** Hane üyeleri (hesap sahibi seçimi için). */ +export async function listMemberOptions(userId: string, householdId: string) { + const detail = await getHouseholdDetail(userId, householdId); + if (!detail) return null; + return detail.members.map((x) => ({ id: x.userId, name: x.name })); +} diff --git a/src/features/categories/actions.ts b/src/features/categories/actions.ts new file mode 100644 index 0000000..6cc5bfa --- /dev/null +++ b/src/features/categories/actions.ts @@ -0,0 +1,65 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { tr } from "@/i18n/tr"; +import { fail, type ActionResult } from "@/lib/action-result"; +import { getHouseholdActionContext } from "@/lib/dal"; +import { categoryFormSchema } from "@/shared/schemas/category"; +import { createCategory, reorderCategory, setCategoryArchived, updateCategory } from "./service"; + +const PATH = "/kategoriler"; +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); +const checkFields = tr.adminUsers.errors.checkFields; + +export async function createCategoryAction(input: unknown): Promise> { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const parsed = categoryFormSchema.safeParse(input); + if (!parsed.success) return fail(checkFields); + const result = await createCategory(ctx.user.id, ctx.household.id, parsed.data); + if (result.ok) revalidatePath(PATH); + return result; +} + +export async function updateCategoryAction( + categoryId: unknown, + input: unknown, +): Promise { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(categoryId); + const parsed = categoryFormSchema.safeParse(input); + if (!id.success || !parsed.success) return fail(checkFields); + const result = await updateCategory(ctx.user.id, ctx.household.id, id.data, parsed.data); + if (result.ok) revalidatePath(PATH); + return result; +} + +export async function reorderCategoryAction( + categoryId: unknown, + direction: unknown, +): Promise { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(categoryId); + const dir = z.enum(["up", "down"]).safeParse(direction); + if (!id.success || !dir.success) return fail(tr.common.genericError); + const result = await reorderCategory(ctx.user.id, ctx.household.id, id.data, dir.data); + if (result.ok) revalidatePath(PATH); + return result; +} + +export async function setCategoryArchivedAction( + categoryId: unknown, + archived: unknown, +): Promise { + const ctx = await getHouseholdActionContext("member"); + if (!ctx) return fail(tr.common.unauthorized); + const id = idSchema.safeParse(categoryId); + const a = z.boolean().safeParse(archived); + if (!id.success || !a.success) return fail(tr.common.genericError); + const result = await setCategoryArchived(ctx.user.id, ctx.household.id, id.data, a.data); + if (result.ok) revalidatePath(PATH); + return result; +} diff --git a/src/features/categories/components/categories-manager.tsx b/src/features/categories/components/categories-manager.tsx new file mode 100644 index 0000000..cf40f8b --- /dev/null +++ b/src/features/categories/components/categories-manager.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { + Archive, + ArchiveRestore, + ArrowDown, + ArrowUp, + MoreHorizontal, + Pencil, + Plus, +} from "lucide-react"; +import { useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { IconBadge } from "@/components/appearance/swatch"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import type { CategoryKind } from "@/shared/schemas/category"; +import { reorderCategoryAction, setCategoryArchivedAction } from "../actions"; +import type { CategoryView } from "../service"; +import { buildTree, MAX_DEPTH, type CategoryNode } from "../tree"; +import { CategoryFormDialog, type CategoryDialogState } from "./category-form-dialog"; + +const t = tr.categories; + +export function CategoriesManager({ + categories, + canEdit, +}: { + categories: CategoryView[]; + canEdit: boolean; +}) { + const [kind, setKind] = useState("expense"); + const [showArchived, setShowArchived] = useState(false); + const [dialog, setDialog] = useState(null); + const [archiving, setArchiving] = useState(null); + const [pending, startTransition] = useTransition(); + + const tree = useMemo( + () => buildTree(categories.filter((c) => c.kind === kind && (showArchived || !c.archived))), + [categories, kind, showArchived], + ); + const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length; + + function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) { + startTransition(async () => { + const result = await promise; + if (result.ok) { + if (success) toast.success(success); + } else toast.error(result.error ?? tr.common.genericError); + }); + } + + function Row({ + node, + index, + siblings, + }: { + node: CategoryNode; + index: number; + siblings: number; + }) { + return ( + <> +
  • + + + {node.name} + + {node.archived ? {t.archived} : null} + {canEdit ? ( + + + + + + {!node.archived ? ( + <> + {node.depth < MAX_DEPTH - 1 ? ( + setDialog({ mode: "create", kind, parentId: node.id })} + > + + {t.addChild} + + ) : null} + setDialog({ mode: "edit", category: node })}> + + {t.edit} + + run(reorderCategoryAction(node.id, "up"))} + > + + {t.moveUp} + + run(reorderCategoryAction(node.id, "down"))} + > + + {t.moveDown} + + + setArchiving(node)}> + + {t.archive} + + + ) : ( + + run(setCategoryArchivedAction(node.id, false), t.unarchivedToast(node.name)) + } + > + + {t.unarchive} + + )} + + + ) : null} +
  • + {node.children.map((child, i) => ( + + ))} + + ); + } + + return ( +
    +
    + setKind(v as CategoryKind)}> + + {t.expense} + {t.income} + + + {archivedCount ? ( +
    + setShowArchived(v === true)} + /> + +
    + ) : null} + {canEdit ? ( + + ) : null} +
    + {!canEdit ?

    {t.readOnly}

    : null} + + {tree.length ? ( +
      + {tree.map((node, i) => ( + + ))} +
    + ) : ( +

    + {t.empty} +

    + )} + + !o && setDialog(null)} + /> + + !o && setArchiving(null)}> + + + {t.archiveTitle} + + {archiving ? t.archiveDescription(archiving.name) : null} + + + + {tr.common.cancel} + + + + +
    + ); +} diff --git a/src/features/categories/components/category-form-dialog.tsx b/src/features/categories/components/category-form-dialog.tsx new file mode 100644 index 0000000..5e5ba35 --- /dev/null +++ b/src/features/categories/components/category-form-dialog.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { useEffect, useMemo } from "react"; +import { Controller, useForm, useWatch } from "react-hook-form"; +import { toast } from "sonner"; +import { IconPicker, SwatchPicker } from "@/components/appearance/pickers"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Button } from "@/components/ui/button"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { tr } from "@/i18n/tr"; +import { + categoryFormSchema, + type CategoryForm, + type CategoryFormInput, + type CategoryKind, +} from "@/shared/schemas/category"; +import { createCategoryAction, updateCategoryAction } from "../actions"; +import type { CategoryView } from "../service"; +import { buildTree, descendantIds, MAX_DEPTH, toOptions } from "../tree"; + +const t = tr.categories; +const ROOT = "__root__"; + +export type CategoryDialogState = + | { mode: "create"; kind: CategoryKind; parentId: string | null } + | { mode: "edit"; category: CategoryView } + | null; + +export function CategoryFormDialog({ + state, + categories, + onOpenChange, +}: { + state: CategoryDialogState; + categories: CategoryView[]; + onOpenChange: (open: boolean) => void; +}) { + const form = useForm({ + resolver: zodResolver(categoryFormSchema), + }); + const kind = state?.mode === "edit" ? state.category.kind : (state?.kind ?? "expense"); + + useEffect(() => { + if (!state) return; + if (state.mode === "edit") { + const c = state.category; + form.reset({ + kind: c.kind, + name: c.name, + parentId: c.parentId ?? "", + icon: c.icon, + color: c.color, + }); + } else { + const parent = categories.find((c) => c.id === state.parentId); + form.reset({ + kind: state.kind, + name: "", + parentId: state.parentId ?? "", + icon: parent?.icon ?? "tag", + color: parent?.color ?? "blue", + }); + } + }, [state, categories, form]); + + /** Üst kategori seçenekleri: aynı tür, aktif; düzenlemede kendisi ve alt kategorileri hariç. */ + const parentOptions = useMemo(() => { + const sameKind = categories.filter((c) => c.kind === kind); + const excluded = new Set( + state?.mode === "edit" + ? [state.category.id, ...descendantIds(sameKind, state.category.id)] + : [], + ); + return toOptions(buildTree(sameKind)).filter( + (o) => !excluded.has(o.id) && o.depth < MAX_DEPTH - 1, + ); + }, [categories, kind, state]); + + /** Ham form değerleri gönderilir; sunucu aynı şemayla yeniden ayrıştırır (tek doğruluk kaynağı). */ + async function onSubmit() { + const values = form.getValues(); + const result = + state?.mode === "edit" + ? await updateCategoryAction(state.category.id, values) + : await createCategoryAction(values); + if (result.ok) { + toast.success(state?.mode === "edit" ? t.updated(values.name) : t.created(values.name)); + onOpenChange(false); + return; + } + for (const [name, message] of Object.entries(result.fieldErrors ?? {})) { + if (message) form.setError(name as keyof CategoryFormInput, { message }); + } + if (!result.fieldErrors) form.setError("root", { message: result.error }); + } + + const color = useWatch({ control: form.control, name: "color" }) ?? "blue"; + + return ( + + + + + } + > +
    + + ( + + {t.name} + + {fieldState.invalid ? : null} + + )} + /> + { + const current = parentOptions.find((o) => o.id === field.value); + return ( + + {t.parent} + + {fieldState.invalid ? : null} + + ); + }} + /> + ( + + {t.color} + + + )} + /> + ( + + {t.icon} + + {t.icon} + + )} + /> + {form.formState.errors.root ? ( + {form.formState.errors.root.message} + ) : null} + +
    +
    + ); +} diff --git a/src/features/categories/defaults.ts b/src/features/categories/defaults.ts new file mode 100644 index 0000000..9a95e08 --- /dev/null +++ b/src/features/categories/defaults.ts @@ -0,0 +1,117 @@ +import type { IconKey, Swatch } from "@/shared/appearance"; +import type { CategoryKind } from "@/shared/schemas/category"; + +/** + * Hane oluşturulunca yüklenen varsayılan Türkçe kategori seti (Bölüm 6.2). + * `key` sabittir: set tekrar yüklendiğinde kopya oluşmaz, kullanıcı adı değiştirse bile tanınır. + */ +export type DefaultCategory = { + key: string; + name: string; + icon: IconKey; + color: Swatch; + children?: Omit[]; +}; + +export const DEFAULT_CATEGORIES: Record = { + expense: [ + { + key: "market", + name: "Market", + icon: "shopping-cart", + color: "green", + children: [ + { key: "market.gida", name: "Gıda", icon: "shopping-basket", color: "green" }, + { key: "market.temizlik", name: "Temizlik", icon: "spray-can", color: "teal" }, + { key: "market.kisisel-bakim", name: "Kişisel bakım", icon: "sparkles", color: "pink" }, + ], + }, + { + key: "faturalar", + name: "Faturalar", + icon: "receipt", + color: "amber", + children: [ + { key: "faturalar.elektrik", name: "Elektrik", icon: "zap", color: "amber" }, + { key: "faturalar.su", name: "Su", icon: "droplet", color: "sky" }, + { key: "faturalar.dogalgaz", name: "Doğalgaz", icon: "flame", color: "orange" }, + { key: "faturalar.internet", name: "İnternet", icon: "wifi", color: "indigo" }, + { key: "faturalar.telefon", name: "Telefon", icon: "smartphone", color: "purple" }, + ], + }, + { key: "kira", name: "Kira", icon: "house", color: "blue" }, + { key: "aidat", name: "Aidat", icon: "building", color: "slate" }, + { + key: "ulasim", + name: "Ulaşım", + icon: "car", + color: "sky", + children: [ + { key: "ulasim.yakit", name: "Yakıt", icon: "fuel", color: "orange" }, + { key: "ulasim.toplu-tasima", name: "Toplu taşıma", icon: "bus", color: "sky" }, + { key: "ulasim.arac-bakim", name: "Araç bakım ve sigorta", icon: "wrench", color: "slate" }, + ], + }, + { + key: "saglik", + name: "Sağlık", + icon: "heart-pulse", + color: "red", + children: [ + { key: "saglik.ilac", name: "İlaç", icon: "pill", color: "red" }, + { key: "saglik.muayene", name: "Muayene ve tahlil", icon: "heart-pulse", color: "rose" }, + ], + }, + { key: "egitim", name: "Eğitim", icon: "graduation-cap", color: "indigo" }, + { key: "giyim", name: "Giyim", icon: "shirt", color: "purple" }, + { + key: "eglence", + name: "Eğlence", + icon: "film", + color: "pink", + children: [ + { + key: "eglence.disarida-yemek", + name: "Dışarıda yemek", + icon: "utensils", + color: "orange", + }, + { key: "eglence.kafe", name: "Kafe", icon: "coffee", color: "amber" }, + { key: "eglence.tatil", name: "Tatil", icon: "plane", color: "sky" }, + ], + }, + { key: "abonelikler", name: "Abonelikler", icon: "repeat", color: "purple" }, + { key: "ev", name: "Ev ve mobilya", icon: "sofa", color: "teal" }, + { key: "cocuk", name: "Çocuk", icon: "baby", color: "rose" }, + { key: "evcil-hayvan", name: "Evcil hayvan", icon: "paw-print", color: "lime" }, + { key: "hediye", name: "Hediye ve bağış", icon: "gift", color: "pink" }, + { + key: "banka", + name: "Banka ve kart giderleri", + icon: "percent", + color: "slate", + children: [ + { key: "banka.faiz", name: "Faiz", icon: "percent", color: "red" }, + { key: "banka.vergi", name: "KKDF / BSMV", icon: "receipt", color: "slate" }, + { + key: "banka.ucret", + name: "Kart ve hesap ücretleri", + icon: "credit-card", + color: "slate", + }, + ], + }, + { key: "vergi", name: "Vergi ve harçlar", icon: "landmark", color: "slate" }, + { key: "diger-gider", name: "Diğer", icon: "circle-ellipsis", color: "slate" }, + ], + income: [ + { key: "maas", name: "Maaş", icon: "briefcase", color: "green" }, + { key: "ek-gelir", name: "Ek gelir", icon: "coins", color: "teal" }, + { key: "yemek-karti", name: "Yemek kartı yüklemesi", icon: "utensils", color: "lime" }, + { key: "kira-geliri", name: "Kira geliri", icon: "house", color: "blue" }, + { key: "getiri", name: "Faiz ve getiri", icon: "trending-up", color: "indigo" }, + { key: "iade", name: "İade", icon: "undo", color: "sky" }, + { key: "hediye-gelir", name: "Hediye", icon: "gift", color: "pink" }, + { key: "diger-gelir", name: "Diğer", icon: "circle-ellipsis", color: "slate" }, + ], +}; diff --git a/src/features/categories/service.int.test.ts b/src/features/categories/service.int.test.ts new file mode 100644 index 0000000..16b73bd --- /dev/null +++ b/src/features/categories/service.int.test.ts @@ -0,0 +1,159 @@ +import { Types } from "mongoose"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { CategoryModel } from "@/models/category"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { + createCategory, + installDefaultCategories, + listCategories, + setCategoryArchived, + updateCategory, +} from "./service"; + +let ids: Awaited>; +let cakir = ""; +let annemler = ""; + +beforeAll(async () => { + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + annemler = ids.household("Annemler"); +}); +afterAll(teardownDb); + +const byKey = (key: string, householdId = cakir) => + CategoryModel.findOne({ householdId, defaultKey: key }) + .lean() + .then((c) => String(c!._id)); +const form = (over: Partial[2]> = {}) => ({ + kind: "expense" as const, + name: "Test", + parentId: null, + icon: "tag" as const, + color: "blue" as const, + ...over, +}); + +describe("varsayılan kategoriler", () => { + it("tekrar yüklemede kopya oluşmaz", async () => { + const before = await CategoryModel.countDocuments({ householdId: cakir }); + const added = await installDefaultCategories( + new Types.ObjectId(cakir), + new Types.ObjectId(ids.user("admin")), + ); + expect(added).toBe(0); + expect(await CategoryModel.countDocuments({ householdId: cakir })).toBe(before); + }); + + it("alt kategoriler doğru ancestors zinciriyle yüklenir", async () => { + const elektrik = await CategoryModel.findOne({ + householdId: cakir, + defaultKey: "faturalar.elektrik", + }).lean(); + const faturalar = await byKey("faturalar"); + expect(elektrik?.ancestors.map(String)).toEqual([faturalar]); + }); +}); + +describe("izolasyon ve roller", () => { + it("başka hanenin kategorilerini okuyamaz ve değiştiremez", async () => { + expect(await listCategories(ids.user("fatma"), cakir)).toBeNull(); + const kira = await byKey("kira"); + // fatma kendi hanesinin kimliğiyle, Çakır'ın kategorisini hedefler → bulunamaz. + const r = await updateCategory( + ids.user("fatma"), + annemler, + kira, + form({ name: "Ele geçirildi" }), + ); + expect(r.ok).toBe(false); + expect((await CategoryModel.findById(kira).lean())?.name).toBe("Kira"); + const r2 = await setCategoryArchived(ids.user("fatma"), annemler, kira, true); + expect(r2.ok).toBe(false); + }); + + it("izleyici ekleyemez, üye ekler", async () => { + expect((await createCategory(ids.user("zeynep"), cakir, form({ name: "Harçlık" }))).ok).toBe( + false, + ); + expect((await createCategory(ids.user("emre"), cakir, form({ name: "Harçlık" }))).ok).toBe( + true, + ); + }); + + it("başka hanenin kategorisini üst kategori yapamaz", async () => { + const annemlerMarket = await byKey("market", annemler); + const r = await createCategory( + ids.user("ayse"), + cakir, + form({ name: "Sızma", parentId: annemlerMarket }), + ); + expect(r.ok).toBe(false); + }); +}); + +describe("ağaç kuralları", () => { + it("aynı düzeyde aynı ad (Türkçe normalize) reddedilir", async () => { + const r = await createCategory(ids.user("ayse"), cakir, form({ name: " KİRA " })); + expect(r.ok).toBe(false); + // farklı düzeyde aynı ad serbest + const market = await byKey("market"); + expect( + (await createCategory(ids.user("ayse"), cakir, form({ name: "Kira", parentId: market }))).ok, + ).toBe(true); + }); + + it("taşıma alt ağacın ancestors alanlarını günceller", async () => { + const ulasim = await byKey("ulasim"); + const yakit = await byKey("ulasim.yakit"); + const created = await createCategory( + ids.user("ayse"), + cakir, + form({ name: "LPG", parentId: yakit }), + ); + const lpg = created.ok ? created.data.id : ""; + const ev = await byKey("ev"); + // Yakıt'ı (alt kategorisi LPG ile) "Ev ve mobilya" altına taşı. + const r = await updateCategory( + ids.user("ayse"), + cakir, + yakit, + form({ name: "Yakıt", parentId: ev, icon: "fuel" }), + ); + expect(r.ok).toBe(true); + expect((await CategoryModel.findById(yakit).lean())?.ancestors.map(String)).toEqual([ev]); + expect((await CategoryModel.findById(lpg).lean())?.ancestors.map(String)).toEqual([ev, yakit]); + expect((await CategoryModel.findById(yakit).lean())?.parentId?.toString()).not.toBe(ulasim); + }); + + it("döngü ve tür uyuşmazlığı reddedilir", async () => { + const market = await byKey("market"); + const gida = await byKey("market.gida"); + expect( + ( + await updateCategory( + ids.user("ayse"), + cakir, + market, + form({ name: "Market", parentId: gida }), + ) + ).ok, + ).toBe(false); + const maas = await byKey("maas"); + expect( + (await updateCategory(ids.user("ayse"), cakir, gida, form({ name: "Gıda", parentId: maas }))) + .ok, + ).toBe(false); + }); + + it("arşivleme alt ağaçla birlikte yapılır; üstü arşivliyken alt kategori geri alınamaz", async () => { + const saglik = await byKey("saglik"); + const ilac = await byKey("saglik.ilac"); + expect((await setCategoryArchived(ids.user("emre"), cakir, saglik, true)).ok).toBe(true); + const active = (await listCategories(ids.user("emre"), cakir))!.map((c) => c.id); + expect(active).not.toContain(saglik); + expect(active).not.toContain(ilac); + expect((await setCategoryArchived(ids.user("emre"), cakir, ilac, false)).ok).toBe(false); + expect((await setCategoryArchived(ids.user("emre"), cakir, saglik, false)).ok).toBe(true); + }); +}); diff --git a/src/features/categories/service.ts b/src/features/categories/service.ts new file mode 100644 index 0000000..4af3981 --- /dev/null +++ b/src/features/categories/service.ts @@ -0,0 +1,331 @@ +import "server-only"; +import { Types, type ClientSession } from "mongoose"; +import { resolveMembership } from "@/features/households/service"; +import { tr } from "@/i18n/tr"; +import { fail, ok, type ActionResult } from "@/lib/action-result"; +import { writeAudit } from "@/lib/audit"; +import { connectMongoose } from "@/lib/db/mongoose"; +import { toObjectId } from "@/lib/object-id"; +import { CategoryModel } from "@/models/category"; +import type { IconKey, Swatch } from "@/shared/appearance"; +import type { CategoryForm, CategoryKind } from "@/shared/schemas/category"; +import { foldTr } from "@/shared/search"; +import { DEFAULT_CATEGORIES } from "./defaults"; +import { + checkCreate, + checkMove, + descendantIds, + type FlatCategory, + type MoveViolation, +} from "./tree"; + +/** + * Kategori servisi. Okuma: viewer; yazma: member. Hane kapsamı her fonksiyonda doğrulanır. + * Silme yoktur: arşivleme (alt ağaçla birlikte). İşlemler arşivli kategoriyi göstermeye devam eder. + */ + +const t = tr.categories; + +export type CategoryView = FlatCategory & { icon: IconKey; color: Swatch; isDefault: boolean }; + +type Lean = { + _id: Types.ObjectId; + kind: CategoryKind; + name: string; + parentId: Types.ObjectId | null; + order: number; + archivedAt: Date | null; + icon: string; + color: string; + defaultKey: string | null; + ancestors: Types.ObjectId[]; +}; + +const toView = (c: Lean): CategoryView => ({ + id: String(c._id), + kind: c.kind, + name: c.name, + parentId: c.parentId ? String(c.parentId) : null, + order: c.order, + archived: Boolean(c.archivedAt), + icon: c.icon as IconKey, + color: c.color as Swatch, + isDefault: Boolean(c.defaultKey), +}); + +const violationMessage = (v: MoveViolation) => t.violations[v]; + +export async function listCategories( + userId: string, + householdId: string, + opts: { kind?: CategoryKind; includeArchived?: boolean } = {}, +): Promise { + const m = await resolveMembership(userId, householdId, "viewer"); + if (!m) return null; + const filter: Record = { householdId: m.householdId }; + if (opts.kind) filter.kind = opts.kind; + if (!opts.includeArchived) filter.archivedAt = null; + const rows = await CategoryModel.find(filter).lean(); + return rows.map(toView); +} + +async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) { + return CategoryModel.find({ householdId, kind }) + .session(session ?? null) + .lean(); +} + +function hasSiblingNamed(all: Lean[], parentId: string | null, name: string, exceptId?: string) { + const key = foldTr(name); + return all.some( + (c) => + !c.archivedAt && + String(c._id) !== exceptId && + (c.parentId ? String(c.parentId) : null) === parentId && + foldTr(c.name) === key, + ); +} + +/** + * Varsayılan seti yükler (idempotent: defaultKey'e göre). Yeni hane oluşturulurken aynı + * transaction içinde (session) ya da migration/seed'den çağrılır. Eklenen kategori sayısını döner. + */ +export async function installDefaultCategories( + householdId: Types.ObjectId, + actor: Types.ObjectId, + session?: ClientSession, +): Promise { + await connectMongoose(); + let inserted = 0; + const upsert = async ( + kind: CategoryKind, + def: { key: string; name: string; icon: string; color: string }, + order: number, + parent: { _id: Types.ObjectId; ancestors: Types.ObjectId[] } | null, + ) => { + const res = await CategoryModel.findOneAndUpdate( + { householdId, defaultKey: def.key }, + { + $setOnInsert: { + householdId, + kind, + name: def.name, + nameKey: foldTr(def.name), + parentId: parent?._id ?? null, + ancestors: parent ? [...parent.ancestors, parent._id] : [], + icon: def.icon, + color: def.color, + order, + defaultKey: def.key, + createdBy: actor, + updatedBy: actor, + }, + }, + { upsert: true, returnDocument: "after", includeResultMetadata: true, session }, + ); + if (!res.lastErrorObject?.updatedExisting) inserted++; + return res.value!; + }; + + for (const kind of ["expense", "income"] as const) { + for (const [i, def] of DEFAULT_CATEGORIES[kind].entries()) { + const parent = await upsert(kind, def, i, null); + for (const [j, child] of (def.children ?? []).entries()) { + await upsert(kind, child, j, { _id: parent._id, ancestors: parent.ancestors }); + } + } + } + return inserted; +} + +export async function createCategory( + actorId: string, + householdId: string, + input: CategoryForm, +): Promise> { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const all = await loadKind(m.householdId, input.kind); + const violation = checkCreate(all.map(toView), input.kind, input.parentId); + if (violation) + return fail(violationMessage(violation), { parentId: violationMessage(violation) }); + if (hasSiblingNamed(all, input.parentId, input.name)) + return fail(t.duplicate, { name: t.duplicate }); + + const parent = input.parentId ? all.find((c) => String(c._id) === input.parentId) : null; + const siblings = all.filter((c) => (c.parentId ? String(c.parentId) : null) === input.parentId); + const actor = new Types.ObjectId(actorId); + const doc = await CategoryModel.create({ + householdId: m.householdId, + kind: input.kind, + name: input.name, + nameKey: foldTr(input.name), + parentId: parent?._id ?? null, + ancestors: parent ? [...parent.ancestors, parent._id] : [], + icon: input.icon, + color: input.color, + order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0, + createdBy: actor, + updatedBy: actor, + }); + return ok({ id: String(doc._id) }); +} + +async function loadOne(householdId: Types.ObjectId, categoryId: string) { + const id = toObjectId(categoryId); + if (!id) return null; + // householdId filtresi: başka hanenin kategorisi asla bulunmaz. + return CategoryModel.findOne({ _id: id, householdId }).lean(); +} + +/** Ad, ikon, renk ve (değiştiyse) üst kategori. Taşıma alt ağaçla birlikte transaction'da. */ +export async function updateCategory( + actorId: string, + householdId: string, + categoryId: string, + input: CategoryForm, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const current = await loadOne(m.householdId, categoryId); + if (!current || current.kind !== input.kind) return fail(tr.common.notFound); + const all = await loadKind(m.householdId, current.kind); + const currentParent = current.parentId ? String(current.parentId) : null; + const moving = currentParent !== input.parentId; + + if (moving) { + const violation = checkMove(all.map(toView), categoryId, input.parentId); + if (violation) + return fail(violationMessage(violation), { parentId: violationMessage(violation) }); + } + if (hasSiblingNamed(all, input.parentId, input.name, categoryId)) + return fail(t.duplicate, { name: t.duplicate }); + + const actor = new Types.ObjectId(actorId); + const mongoose = await connectMongoose(); + await mongoose.connection.transaction(async (session) => { + const set: Record = { + name: input.name, + nameKey: foldTr(input.name), + icon: input.icon, + color: input.color, + updatedBy: actor, + }; + if (moving) { + const parent = input.parentId ? all.find((c) => String(c._id) === input.parentId)! : null; + const newAncestors = parent ? [...parent.ancestors, parent._id] : []; + const siblings = all.filter( + (c) => (c.parentId ? String(c.parentId) : null) === input.parentId, + ); + Object.assign(set, { + parentId: parent?._id ?? null, + ancestors: newAncestors, + order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0, + }); + // Alt ağaç: yeni üst zincir + (taşınan düğüm) + düğümün altındaki eski yol. + for (const d of all.filter((c) => c.ancestors.some((a) => a.equals(current._id)))) { + const idx = d.ancestors.findIndex((a) => a.equals(current._id)); + await CategoryModel.updateOne( + { _id: d._id, householdId: m.householdId }, + { $set: { ancestors: [...newAncestors, current._id, ...d.ancestors.slice(idx + 1)] } }, + { session }, + ); + } + await writeAudit( + { + action: "category.move", + actorId: actor, + householdId: m.householdId, + targetType: "category", + targetId: categoryId, + diff: { parentId: { from: currentParent, to: input.parentId } }, + }, + session, + ); + } + await CategoryModel.updateOne( + { _id: current._id, householdId: m.householdId }, + { $set: set }, + { session }, + ); + }); + return ok(undefined); +} + +/** Kardeşler arasında bir yukarı/aşağı taşır. */ +export async function reorderCategory( + actorId: string, + householdId: string, + categoryId: string, + direction: "up" | "down", +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const current = await loadOne(m.householdId, categoryId); + if (!current) return fail(tr.common.notFound); + const siblings = (await loadKind(m.householdId, current.kind)) + .filter((c) => !c.archivedAt && String(c.parentId ?? "") === String(current.parentId ?? "")) + .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr")); + const i = siblings.findIndex((c) => c._id.equals(current._id)); + const j = direction === "up" ? i - 1 : i + 1; + if (i < 0 || j < 0 || j >= siblings.length) return ok(undefined); + [siblings[i], siblings[j]] = [siblings[j]!, siblings[i]!]; + await CategoryModel.bulkWrite( + siblings.map((c, order) => ({ + updateOne: { + filter: { _id: c._id, householdId: m.householdId }, + update: { $set: { order } }, + }, + })), + ); + return ok(undefined); +} + +/** Arşivler (alt kategorilerle birlikte) veya arşivden çıkarır (üstü aktif olmalı). */ +export async function setCategoryArchived( + actorId: string, + householdId: string, + categoryId: string, + archived: boolean, +): Promise { + const m = await resolveMembership(actorId, householdId, "member"); + if (!m) return fail(tr.common.unauthorized); + const current = await loadOne(m.householdId, categoryId); + if (!current) return fail(tr.common.notFound); + const all = await loadKind(m.householdId, current.kind); + + if (!archived && current.parentId) { + const parent = all.find((c) => c._id.equals(current.parentId!)); + if (parent?.archivedAt) return fail(t.violations.parentArchived); + } + if ( + !archived && + hasSiblingNamed( + all, + current.parentId ? String(current.parentId) : null, + current.name, + categoryId, + ) + ) { + return fail(t.duplicate); + } + + const ids = [ + current._id, + ...descendantIds(all.map(toView), categoryId).map((id) => new Types.ObjectId(id)), + ]; + const actor = new Types.ObjectId(actorId); + await CategoryModel.updateMany( + { _id: { $in: ids }, householdId: m.householdId }, + { $set: { archivedAt: archived ? new Date() : null, updatedBy: actor } }, + ); + await writeAudit({ + action: archived ? "category.archive" : "category.unarchive", + actorId: actor, + householdId: m.householdId, + targetType: "category", + targetId: categoryId, + diff: { name: current.name, affected: ids.length }, + }); + return ok(undefined); +} diff --git a/src/features/categories/tree.test.ts b/src/features/categories/tree.test.ts new file mode 100644 index 0000000..e532e9a --- /dev/null +++ b/src/features/categories/tree.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + ancestorIds, + buildTree, + checkCreate, + checkMove, + descendantIds, + rollupTotals, + toOptions, + type FlatCategory, +} from "./tree"; + +const c = ( + id: string, + name: string, + parentId: string | null, + order = 0, + kind: "expense" | "income" = "expense", +): FlatCategory => ({ + id, + kind, + name, + parentId, + order, + archived: false, +}); + +const items: FlatCategory[] = [ + c("fat", "Faturalar", null, 1), + c("elk", "Elektrik", "fat", 0), + c("su", "Su", "fat", 1), + c("mar", "Market", null, 0), + c("gida", "Gıda", "mar", 0), + c("meyve", "Meyve", "gida", 0), + c("maas", "Maaş", null, 0, "income"), +]; + +describe("buildTree / toOptions", () => { + it("sıra ve derinlikle ağaç kurar, yol etiketleri üretir", () => { + const tree = buildTree(items.filter((i) => i.kind === "expense")); + expect(tree.map((n) => n.name)).toEqual(["Market", "Faturalar"]); + expect(toOptions(tree).map((o) => [o.path, o.depth])).toEqual([ + ["Market", 0], + ["Market › Gıda", 1], + ["Market › Gıda › Meyve", 2], + ["Faturalar", 0], + ["Faturalar › Elektrik", 1], + ["Faturalar › Su", 1], + ]); + }); + + it("arşivlenenleri varsayılan olarak seçeneklere koymaz", () => { + const tree = buildTree([c("a", "A", null), { ...c("b", "B", "a"), archived: true }]); + expect(toOptions(tree).map((o) => o.id)).toEqual(["a"]); + expect(toOptions(tree, { includeArchived: true }).map((o) => o.id)).toEqual(["a", "b"]); + }); + + it("üstü olmayan düğümü köke alır, döngüde takılmaz", () => { + const loop = [c("x", "X", "y"), c("y", "Y", "x"), c("z", "Z", "yok")]; + expect(() => buildTree(loop)).not.toThrow(); + expect(buildTree(loop).map((n) => n.id)).toContain("z"); + }); +}); + +describe("ancestorIds / descendantIds", () => { + it("üst ve alt kategorileri bulur", () => { + expect(ancestorIds(items, "meyve")).toEqual(["mar", "gida"]); + expect(descendantIds(items, "mar").sort()).toEqual(["gida", "meyve"]); + expect(descendantIds(items, "elk")).toEqual([]); + }); +}); + +describe("checkMove", () => { + it("kendine, alt kategorisine ve farklı türe taşımayı reddeder", () => { + expect(checkMove(items, "mar", "mar")).toBe("self"); + expect(checkMove(items, "mar", "meyve")).toBe("descendant"); + expect(checkMove(items, "elk", "maas")).toBe("kind"); + expect(checkMove(items, "elk", "yok")).toBe("parentNotFound"); + }); + + it("geçerli taşımaya ve köke taşımaya izin verir", () => { + expect(checkMove(items, "su", "mar")).toBeNull(); + expect(checkMove(items, "gida", null)).toBeNull(); + }); + + it("derinlik sınırını uygular (en fazla 5 seviye: derinlik 0–4)", () => { + const deep = [ + c("1", "1", null), + c("2", "2", "1"), + c("3", "3", "2"), + c("4", "4", "3"), + c("5", "5", "4"), + c("x", "X", null), + c("y", "Y", "x"), + ]; + expect(checkCreate(deep, "expense", "4")).toBeNull(); // yeni düğüm derinlik 4 + expect(checkCreate(deep, "expense", "5")).toBe("depth"); // derinlik 5 + expect(checkMove(deep, "y", "4")).toBeNull(); + expect(checkMove(deep, "y", "5")).toBe("depth"); + expect(checkMove(deep, "x", "4")).toBe("depth"); // x derinlik 4, alt kategorisi y derinlik 5 + expect(checkCreate(deep, "income", "3")).toBe("kind"); + }); +}); + +describe("rollupTotals", () => { + it("alt kategori tutarlarını üst kategorilere toplar", () => { + const totals = rollupTotals( + items, + new Map([ + ["meyve", 300], + ["gida", 500], + ["elk", 1000], + ["su", 250], + ]), + ); + expect(totals.get("mar")).toBe(800); + expect(totals.get("gida")).toBe(800); + expect(totals.get("fat")).toBe(1250); + expect(totals.get("maas") ?? 0).toBe(0); + }); +}); diff --git a/src/features/categories/tree.ts b/src/features/categories/tree.ts new file mode 100644 index 0000000..91ecad6 --- /dev/null +++ b/src/features/categories/tree.ts @@ -0,0 +1,163 @@ +import type { CategoryKind } from "@/shared/schemas/category"; +import { compareTr } from "@/shared/search"; + +/** + * Kategori ağacı saf fonksiyonları (framework'ten bağımsız, birim testli). + * Derinlik pratikte 3 seviye; güvenlik için en fazla MAX_DEPTH seviye. + */ +export const MAX_DEPTH = 5; + +export type FlatCategory = { + id: string; + kind: CategoryKind; + name: string; + parentId: string | null; + order: number; + archived: boolean; +}; + +export type CategoryNode = T & { + depth: number; + children: CategoryNode[]; +}; + +const bySortOrder = (a: FlatCategory, b: FlatCategory) => + a.order - b.order || compareTr(a.name, b.name); + +/** Düz listeden ağaç kurar. Üstü bulunamayan (veya döngülü) düğümler köke alınır. */ +export function buildTree(items: readonly T[]): CategoryNode[] { + const nodes = new Map( + items.map((c) => [c.id, { ...c, depth: 0, children: [] as CategoryNode[] }]), + ); + const roots: CategoryNode[] = []; + for (const node of nodes.values()) { + const parent = node.parentId ? nodes.get(node.parentId) : undefined; + if (parent && parent !== node) parent.children.push(node); + else roots.push(node); + } + const visit = (list: CategoryNode[], depth: number, seen: Set) => { + list.sort(bySortOrder); + for (const n of list) { + if (seen.has(n.id)) continue; + seen.add(n.id); + n.depth = depth; + visit(n.children, depth + 1, seen); + } + }; + visit(roots, 0, new Set()); + return roots; +} + +export type CategoryOption = { + id: string; + label: string; + path: string; + depth: number; + kind: CategoryKind; +}; + +/** Seçim listesi (işlem formları): derinlik sırasıyla, "Faturalar › Elektrik" yoluyla. */ +export function toOptions( + tree: CategoryNode[], + { includeArchived = false } = {}, +): CategoryOption[] { + const out: CategoryOption[] = []; + const walk = (list: CategoryNode[], prefix: string[]) => { + for (const n of list) { + if (n.archived && !includeArchived) continue; + const path = [...prefix, n.name]; + out.push({ id: n.id, label: n.name, path: path.join(" › "), depth: n.depth, kind: n.kind }); + walk(n.children, path); + } + }; + walk(tree, []); + return out; +} + +/** `id`'nin tüm alt kategorileri (kendisi hariç). */ +export function descendantIds(items: readonly FlatCategory[], id: string): string[] { + const children = new Map(); + for (const c of items) + if (c.parentId) children.set(c.parentId, [...(children.get(c.parentId) ?? []), c.id]); + const out: string[] = []; + const stack = [...(children.get(id) ?? [])]; + while (stack.length) { + const next = stack.pop()!; + if (out.includes(next)) continue; + out.push(next); + stack.push(...(children.get(next) ?? [])); + } + return out; +} + +/** Kökten `id`'ye kadar üst kategori kimlikleri (id hariç). */ +export function ancestorIds(items: readonly FlatCategory[], id: string): string[] { + const byId = new Map(items.map((c) => [c.id, c])); + const out: string[] = []; + let cur = byId.get(id)?.parentId ?? null; + while (cur && !out.includes(cur)) { + out.unshift(cur); + cur = byId.get(cur)?.parentId ?? null; + } + return out; +} + +/** Alt ağacın yüksekliği (yaprak = 0). */ +function subtreeHeight(items: readonly FlatCategory[], id: string): number { + const kids = items.filter((c) => c.parentId === id); + return kids.length ? 1 + Math.max(...kids.map((k) => subtreeHeight(items, k.id))) : 0; +} + +export type MoveViolation = + "self" | "descendant" | "kind" | "depth" | "parentNotFound" | "parentArchived"; + +/** `id` kategorisi `newParentId` altına (null = kök) taşınabilir mi? */ +export function checkMove( + items: readonly FlatCategory[], + id: string, + newParentId: string | null, +): MoveViolation | null { + if (newParentId === null) return null; + if (newParentId === id) return "self"; + const parent = items.find((c) => c.id === newParentId); + const self = items.find((c) => c.id === id); + if (!parent || !self) return "parentNotFound"; + if (parent.kind !== self.kind) return "kind"; + if (parent.archived) return "parentArchived"; + if (descendantIds(items, id).includes(newParentId)) return "descendant"; + const newDepth = ancestorIds(items, newParentId).length + 1; + if (newDepth + subtreeHeight(items, id) >= MAX_DEPTH) return "depth"; + return null; +} + +/** Yeni kategori `parentId` altına eklenebilir mi? */ +export function checkCreate( + items: readonly FlatCategory[], + kind: CategoryKind, + parentId: string | null, +): MoveViolation | null { + if (!parentId) return null; + const parent = items.find((c) => c.id === parentId); + if (!parent) return "parentNotFound"; + if (parent.kind !== kind) return "kind"; + if (parent.archived) return "parentArchived"; + if (ancestorIds(items, parentId).length + 1 >= MAX_DEPTH) return "depth"; + return null; +} + +/** + * Kategori bazlı tutarları üst kategorilere toplar (raporlar ve bütçe için): + * her kategori kendi tutarı + tüm alt kategorilerinin tutarı. + */ +export function rollupTotals( + items: readonly FlatCategory[], + totals: ReadonlyMap, +): Map { + const out = new Map(); + for (const c of items) { + const own = totals.get(c.id) ?? 0; + for (const target of [c.id, ...ancestorIds(items, c.id)]) + out.set(target, (out.get(target) ?? 0) + own); + } + return out; +} diff --git a/src/features/households/service.ts b/src/features/households/service.ts index 4b2a3ec..cac42f2 100644 --- a/src/features/households/service.ts +++ b/src/features/households/service.ts @@ -6,6 +6,7 @@ import { diffFields, writeAudit } from "@/lib/audit"; import { getDb } from "@/lib/db/mongo"; import { connectMongoose } from "@/lib/db/mongoose"; import { toObjectId } from "@/lib/object-id"; +import { installDefaultCategories } from "@/features/categories/service"; import { HouseholdModel } from "@/models/household"; import { HouseholdMemberModel } from "@/models/household-member"; import type { HouseholdSettings } from "@/shared/schemas/household"; @@ -321,7 +322,8 @@ export async function adminCreateHousehold( }, session, ); - // FAZ 6: varsayılan Türkçe kategori seti bu transaction içinde yüklenecek. + // Varsayılan Türkçe kategori seti aynı transaction'da (Bölüm 6.2). + await installDefaultCategories(household!._id, actor, session); }); return ok({ id: createdId }); } diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index c40a814..c2ad9e8 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -34,6 +34,21 @@ export const tr = { unauthorized: "Bu işlem için yetkiniz yok.", notFound: "Kayıt bulunamadı.", }, + swatches: { + blue: "Mavi", + sky: "Gök mavisi", + teal: "Turkuaz", + green: "Yeşil", + lime: "Fıstık yeşili", + amber: "Kehribar", + orange: "Turuncu", + red: "Kırmızı", + rose: "Gül", + pink: "Pembe", + purple: "Mor", + indigo: "Çivit", + slate: "Gri", + }, theme: { label: "Tema", light: "Açık", @@ -45,6 +60,7 @@ export const tr = { dashboard: "Ana sayfa", transactions: "İşlemler", accounts: "Hesaplar", + categories: "Kategoriler", budget: "Bütçe", planned: "Planlı işlemler", contacts: "Cari hesaplar", @@ -122,6 +138,143 @@ export const tr = { memberRemoved: (name: string) => `${name} haneden çıkarıldı.`, banned: "Askıda", }, + accounts: { + title: "Hesaplar", + description: "Nakit, banka, birikim, kredi kartı ve yemek kartı hesapları.", + newAccount: "Yeni hesap", + createTitle: "Yeni hesap", + editTitle: "Hesabı düzenle", + formDescription: "Güncel bakiye işlemlerden hesaplanır; açılış bakiyesi başlangıç noktasıdır.", + types: { + cash: "Nakit", + bank: "Vadesiz banka", + savings: "Birikim / vadeli", + credit_card: "Kredi kartı", + meal_card: "Yemek kartı", + }, + typeGroups: { + cash: "Nakit", + bank: "Banka hesapları", + savings: "Birikim hesapları", + credit_card: "Kredi kartları", + meal_card: "Yemek kartları", + }, + providers: { + pluxee: "Pluxee (Sodexo)", + multinet: "Multinet", + edenred: "Edenred (Ticket)", + setcard: "Setcard", + metropol: "Metropol", + other: "Diğer", + }, + fields: { + name: "Hesap adı", + type: "Tür", + typeLockedHint: "Hesap türü oluşturulduktan sonra değiştirilemez.", + institution: "Banka / kurum", + institutionPlaceholder: "ör. Ziraat Bankası", + provider: "Kart sağlayıcısı", + openingBalance: "Açılış bakiyesi (TL)", + openingDebt: "Açılış borcu (TL)", + openingBalanceHint: "Banka hesabı KMH kullanıyorsa eksi girilebilir (ör. -2.500,00).", + openingDebtHint: "Karttaki mevcut toplam borç; pozitif girin.", + openingDate: "Açılış tarihi", + owner: "Hesap sahibi", + ownerNone: "— Ortak hesap —", + isPrivate: "Yalnızca ben (bakiyeyi yalnızca hesap sahibi görür)", + appearance: "Görünüm", + advanced: "Gelişmiş ayarlar", + overdraft: "Esnek hesap (KMH)", + overdraftEnabled: "Bu hesapta KMH var", + overdraftLimit: "KMH limiti (TL)", + overdraftRate: "Aylık faiz oranı (%)", + overdraftStatementDay: "Hesap kesim günü", + card: "Kart bilgileri", + cardLimit: "Kart limiti (TL)", + cardStatementDay: "Hesap kesim günü", + cardStatementDayHint: "Ayda o gün yoksa (ör. 31) ayın son günü kullanılır.", + cardDueKind: "Son ödeme günü", + dueKinds: { daysAfter: "Kesimden N gün sonra", fixedDay: "Ayın sabit günü" }, + cardDueValueDays: "Gün sayısı", + cardDueValueDay: "Ayın günü", + cardPaymentAccount: "Ödeme hesabı", + cardPaymentAccountNone: "— Seçilmedi —", + }, + summary: { + assets: "Varlıklar", + cardDebt: "Kart borçları", + net: "Net", + includesHidden: "Toplamlara gizli hesaplar dahil.", + excludesHidden: "Gizli hesaplar toplamlara dahil değil (hane ayarı).", + }, + balance: "Bakiye", + debt: "Borç", + hidden: "Gizli", + hiddenHint: "Bu hesap “yalnızca ben” olarak işaretli; bakiyeyi yalnızca hesap sahibi görür.", + privateBadge: "Yalnızca ben", + overdraftBadge: (limit: string) => `KMH ${limit}`, + limitLabel: (limit: string) => `Limit ${limit}`, + statementLabel: (day: number) => `Kesim: ${day}`, + empty: "Henüz hesap yok.", + emptyOwnerHint: "Başlamak için ilk hesabınızı ekleyin (ör. nakit cüzdan, maaş hesabı).", + archivedSection: (n: number) => `Arşivlenmiş hesaplar (${n})`, + archive: "Arşivle", + unarchive: "Arşivden çıkar", + archiveTitle: "Hesap arşivlensin mi?", + archiveDescription: (name: string) => + `“${name}” yeni işlemlerde seçilemez; geçmiş işlemler ve raporlar korunur. İstediğiniz zaman arşivden çıkarabilirsiniz.`, + created: (name: string) => `${name} oluşturuldu.`, + updated: (name: string) => `${name} güncellendi.`, + archivedToast: (name: string) => `${name} arşivlendi.`, + unarchivedToast: (name: string) => `${name} arşivden çıkarıldı.`, + ownerNotMember: "Hesap sahibi bu hanenin üyesi olmalı.", + invalidPaymentAccount: + "Ödeme hesabı bu hanedeki aktif bir banka, nakit veya birikim hesabı olmalı.", + typeLocked: "Hesap türü değiştirilemez.", + ownerOnly: "Hesapları yalnızca hane sahipleri yönetebilir.", + }, + categories: { + title: "Kategoriler", + description: "Gelir ve gider kategorileri. Alt kategoriler raporlarda üst kategoriye toplanır.", + expense: "Gider", + income: "Gelir", + newCategory: "Yeni kategori", + addChild: "Alt kategori ekle", + edit: "Düzenle", + moveUp: "Yukarı taşı", + moveDown: "Aşağı taşı", + archive: "Arşivle", + unarchive: "Arşivden çıkar", + showArchived: "Arşivi göster", + archived: "Arşivli", + defaultBadge: "Varsayılan", + empty: "Bu türde kategori yok.", + createTitle: "Yeni kategori", + editTitle: "Kategoriyi düzenle", + formDescription: "Alt kategori için bir üst kategori seçin; boş bırakılırsa ana kategori olur.", + name: "Ad", + parent: "Üst kategori", + noParent: "— Ana kategori —", + icon: "İkon", + color: "Renk", + created: (name: string) => `${name} oluşturuldu.`, + updated: (name: string) => `${name} güncellendi.`, + archivedToast: (name: string) => `${name} arşivlendi.`, + unarchivedToast: (name: string) => `${name} arşivden çıkarıldı.`, + archiveTitle: "Kategori arşivlensin mi?", + archiveDescription: (name: string) => + `“${name}” ve alt kategorileri yeni işlemlerde seçilemez. Geçmiş işlemler ve raporlar etkilenmez.`, + duplicate: "Bu düzeyde aynı adlı bir kategori zaten var.", + violations: { + self: "Kategori kendi altına taşınamaz.", + descendant: "Kategori kendi alt kategorisinin altına taşınamaz.", + kind: "Gelir ve gider kategorileri birbirinin altına konamaz.", + depth: "En fazla 5 seviye derinliğe izin verilir.", + parentNotFound: "Üst kategori bulunamadı.", + parentArchived: "Üst kategori arşivli; önce onu arşivden çıkarın.", + }, + readOnly: "İzleyici rolündesiniz; kategorileri değiştiremezsiniz.", + }, adminHouseholds: { title: "Haneler", description: "Hane oluşturma, kullanıcıları haneye atama ve hane içi roller.", @@ -281,6 +434,13 @@ export const tr = { householdNameMax: "Hane adı en fazla 80 karakter olabilir", selectUser: "Bir kullanıcı seçin", wholeNumber: "Tam sayı girin", + money: "Geçerli bir tutar girin (ör. 1.250,50)", + moneyNonNegative: "Tutar negatif olamaz", + percent: "0 ile 100 arasında bir oran girin (ör. 4,25)", + date: "Geçerli bir tarih seçin", + selectProvider: "Kart sağlayıcısını seçin", + privateNeedsOwner: "“Yalnızca ben” hesabı için hesap sahibini seçin", + dueDays: "Kesimden sonra en fazla 30 gün olabilir", }, errors: { notFoundTitle: "Sayfa bulunamadı", diff --git a/src/models/account.ts b/src/models/account.ts new file mode 100644 index 0000000..cd54770 --- /dev/null +++ b/src/models/account.ts @@ -0,0 +1,67 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { ACCOUNT_TYPES, DUE_RULE_KINDS, MEAL_CARD_PROVIDERS } from "@/shared/accounts"; +import { ICON_KEYS, SWATCHES } from "@/shared/appearance"; + +/** + * Hesap (Bölüm 6.1). Tutarlar kuruş. Bakiye işareti: varlık pozitif, kart borcu negatif. + * Güncel bakiyenin tek doğruluk kaynağı işlemlerdir (Faz 6b); `balanceCacheMinor` yalnızca önbellek. + */ +const overdraftSchema = new Schema( + { + enabled: { type: Boolean, default: false }, + limitMinor: { type: Number, min: 0, default: 0 }, + /** Aylık faiz oranı, baz puan (4,25% → 425). */ + monthlyRateBp: { type: Number, min: 0, max: 10_000, default: null }, + statementDay: { type: Number, min: 1, max: 31, default: null }, + }, + { _id: false }, +); + +const creditCardSchema = new Schema( + { + limitMinor: { type: Number, min: 0, default: 0 }, + /** Hesap kesim günü; ayda yoksa (31) ayın son günü kullanılır (Faz 7). */ + statementDay: { type: Number, min: 1, max: 31, default: 1 }, + dueRule: { + kind: { type: String, enum: DUE_RULE_KINDS, default: "daysAfter" }, + value: { type: Number, min: 1, max: 31, default: 10 }, + }, + /** Kart borcunun ödeneceği varsayılan hesap. */ + paymentAccountId: { type: Schema.Types.ObjectId, default: null }, + }, + { _id: false }, +); + +const accountSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + name: { type: String, required: true, trim: true, maxlength: 80 }, + type: { type: String, enum: ACCOUNT_TYPES, required: true }, + institution: { type: String, trim: true, maxlength: 80, default: "" }, + mealCardProvider: { type: String, enum: [...MEAL_CARD_PROVIDERS, null], default: null }, + /** İleride döviz hesabı için; şimdilik yalnızca TRY. */ + currency: { type: String, enum: ["TRY"], default: "TRY" }, + openingBalanceMinor: { type: Number, required: true, default: 0 }, + openingDate: { type: Date, required: true }, + color: { type: String, enum: SWATCHES, default: "blue" }, + icon: { type: String, enum: ICON_KEYS, default: "wallet" }, + order: { type: Number, default: 0 }, + archivedAt: { type: Date, default: null }, + /** Hesabın sahibi kişi (hane üyesi). "Yalnızca ben" hesaplarında bakiyeyi yalnızca o görür. */ + ownerUserId: { type: Schema.Types.ObjectId, default: null }, + isPrivate: { type: Boolean, default: false }, + overdraft: { type: overdraftSchema, default: null }, + creditCard: { type: creditCardSchema, default: null }, + balanceCacheMinor: { type: Number, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "accounts" }, +); + +accountSchema.index({ householdId: 1, archivedAt: 1, order: 1 }); + +export type Account = InferSchemaType; + +export const AccountModel: Model = + (models.Account as Model | undefined) ?? model("Account", accountSchema); diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index ea2f870..37a9484 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -17,6 +17,13 @@ export const AUDIT_ACTIONS = [ "user.ban", "user.unban", "user.remove", + "account.create", + "account.update", + "account.archive", + "account.unarchive", + "category.archive", + "category.unarchive", + "category.move", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/models/category.ts b/src/models/category.ts new file mode 100644 index 0000000..e36a91d --- /dev/null +++ b/src/models/category.ts @@ -0,0 +1,38 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { ICON_KEYS, SWATCHES } from "@/shared/appearance"; +import { CATEGORY_KINDS } from "@/shared/schemas/category"; + +/** Gelir/gider kategori ağacı (Bölüm 6.2). Silme yok; arşivleme (alt ağaçla birlikte). */ +const categorySchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + kind: { type: String, enum: CATEGORY_KINDS, required: true }, + name: { type: String, required: true, trim: true, maxlength: 80 }, + /** Kardeşler arasında ad tekrarını önlemek için normalize ad (foldTr). */ + nameKey: { type: String, required: true }, + parentId: { type: Schema.Types.ObjectId, default: null }, + /** Kökten üst kategoriye kadar kimlikler (alt ağaç sorguları ve toplama için). */ + ancestors: { type: [Schema.Types.ObjectId], default: [] }, + icon: { type: String, enum: ICON_KEYS, default: "tag" }, + color: { type: String, enum: SWATCHES, default: "slate" }, + order: { type: Number, default: 0 }, + archivedAt: { type: Date, default: null }, + /** Varsayılan setten geldiyse sabit anahtar (tekrar yüklemede kopya olmaz). */ + defaultKey: { type: String, default: null }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + updatedBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "categories" }, +); + +categorySchema.index({ householdId: 1, kind: 1, parentId: 1, order: 1 }); +categorySchema.index({ householdId: 1, ancestors: 1 }); +categorySchema.index( + { householdId: 1, defaultKey: 1 }, + { unique: true, partialFilterExpression: { defaultKey: { $type: "string" } } }, +); + +export type Category = InferSchemaType; + +export const CategoryModel: Model = + (models.Category as Model | undefined) ?? model("Category", categorySchema); diff --git a/src/shared/accounts.ts b/src/shared/accounts.ts new file mode 100644 index 0000000..957b774 --- /dev/null +++ b/src/shared/accounts.ts @@ -0,0 +1,20 @@ +/** Hesap türleri ve yemek kartı sağlayıcıları (Bölüm 6.1). */ +export const ACCOUNT_TYPES = ["cash", "bank", "savings", "credit_card", "meal_card"] as const; +export type AccountType = (typeof ACCOUNT_TYPES)[number]; + +export const MEAL_CARD_PROVIDERS = [ + "pluxee", + "multinet", + "edenred", + "setcard", + "metropol", + "other", +] as const; +export type MealCardProvider = (typeof MEAL_CARD_PROVIDERS)[number]; + +/** Kredi kartı son ödeme kuralı: kesimden N gün sonra | ayın sabit günü. */ +export const DUE_RULE_KINDS = ["daysAfter", "fixedDay"] as const; +export type DueRuleKind = (typeof DUE_RULE_KINDS)[number]; + +/** Borç (negatif bakiye) taşıyan hesap türleri. */ +export const isLiabilityType = (type: AccountType) => type === "credit_card"; diff --git a/src/shared/appearance.ts b/src/shared/appearance.ts new file mode 100644 index 0000000..0a4df60 --- /dev/null +++ b/src/shared/appearance.ts @@ -0,0 +1,71 @@ +/** + * Kullanıcı verisindeki renk ve ikonlar anahtar olarak saklanır (sabit renk kodu yok). + * Renkler globals.css'teki --swatch-* değişkenlerine, ikonlar lucide bileşenlerine eşlenir. + */ +export const SWATCHES = [ + "blue", + "sky", + "teal", + "green", + "lime", + "amber", + "orange", + "red", + "rose", + "pink", + "purple", + "indigo", + "slate", +] as const; +export type Swatch = (typeof SWATCHES)[number]; + +export const ICON_KEYS = [ + "wallet", + "banknote", + "landmark", + "piggy-bank", + "credit-card", + "utensils", + "shopping-cart", + "shopping-basket", + "receipt", + "zap", + "droplet", + "flame", + "wifi", + "smartphone", + "house", + "building", + "car", + "fuel", + "bus", + "train", + "heart-pulse", + "pill", + "graduation-cap", + "book", + "shirt", + "gamepad", + "film", + "music", + "coffee", + "plane", + "gift", + "baby", + "paw-print", + "wrench", + "sofa", + "repeat", + "briefcase", + "trending-up", + "coins", + "percent", + "undo", + "sparkles", + "spray-can", + "scissors", + "dumbbell", + "tag", + "circle-ellipsis", +] as const; +export type IconKey = (typeof ICON_KEYS)[number]; diff --git a/src/shared/dates.test.ts b/src/shared/dates.test.ts new file mode 100644 index 0000000..3ad9a52 --- /dev/null +++ b/src/shared/dates.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { istanbulDayStart, toIstanbulYmd, todayIstanbul } from "./dates"; + +describe("İstanbul günü", () => { + it("gün başlangıcı UTC+3'e göre hesaplanır", () => { + expect(istanbulDayStart("2026-11-05")?.toISOString()).toBe("2026-11-04T21:00:00.000Z"); + expect(istanbulDayStart("2027-01-01")?.toISOString()).toBe("2026-12-31T21:00:00.000Z"); + }); + + it("geçersiz tarihleri reddeder", () => { + expect(istanbulDayStart("2026-02-30")).toBeNull(); + expect(istanbulDayStart("05.11.2026")).toBeNull(); + }); + + it("anı İstanbul gününe çevirir (gece yarısı sınırı)", () => { + expect(toIstanbulYmd("2026-12-31T20:59:59Z")).toBe("2026-12-31"); + expect(toIstanbulYmd("2026-12-31T21:00:00Z")).toBe("2027-01-01"); + expect(todayIstanbul(new Date("2026-11-04T22:30:00Z"))).toBe("2026-11-05"); + }); + + it("gidiş-dönüş tutarlıdır", () => { + for (const ymd of ["2026-03-31", "2026-10-25", "2028-02-29"]) { + expect(toIstanbulYmd(istanbulDayStart(ymd)!)).toBe(ymd); + } + }); +}); diff --git a/src/shared/dates.ts b/src/shared/dates.ts new file mode 100644 index 0000000..f804af0 --- /dev/null +++ b/src/shared/dates.ts @@ -0,0 +1,51 @@ +import { TIME_ZONE } from "./format"; + +/** + * İstanbul günü yardımcıları. Veritabanında UTC saklanır; gün sınırları İstanbul'a göredir. + * Türkiye 2016'dan beri sabit UTC+3 kullanır; yine de dönüşüm Intl ile yapılır (kural değişirse + * yalnızca ofset hesabı güncellenir). + */ + +const ymdFmt = new Intl.DateTimeFormat("en-CA", { + timeZone: TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +/** Bir anın İstanbul'daki takvim günü: "2026-11-05". */ +export function toIstanbulYmd(date: Date | string | number): string { + return ymdFmt.format(new Date(date)); +} + +/** Bugünün İstanbul günü. */ +export function todayIstanbul(now: Date = new Date()): string { + return toIstanbulYmd(now); +} + +/** İstanbul'un verilen andaki UTC ofseti (dakika; +180). */ +function istanbulOffsetMinutes(at: Date): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: TIME_ZONE, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).formatToParts(at); + const get = (t: string) => Number(parts.find((p) => p.type === t)?.value); + const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute")); + return Math.round((asUtc - at.getTime()) / 60_000); +} + +/** "2026-11-05" → o günün İstanbul'daki başlangıç anı (UTC Date). Geçersizse null. */ +export function istanbulDayStart(ymd: string): Date | null { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd); + if (!m) return null; + const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; + const guess = new Date(Date.UTC(y, mo - 1, d)); + if (guess.getUTCFullYear() !== y || guess.getUTCMonth() !== mo - 1 || guess.getUTCDate() !== d) + return null; + return new Date(guess.getTime() - istanbulOffsetMinutes(guess) * 60_000); +} diff --git a/src/shared/money.test.ts b/src/shared/money.test.ts new file mode 100644 index 0000000..228ee47 --- /dev/null +++ b/src/shared/money.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { allocate, formatMoneyInput, parseMoney, splitEvenly, sumMoney } from "./money"; + +describe("parseMoney", () => { + it.each([ + ["1.250,50", 125050], + ["1250,50", 125050], + ["1250,5", 125050], + ["1250", 125000], + ["0,01", 1], + [",5", 50], + ["1.250", 125000], + ["12.345.678,90", 1234567890], + ["12.5", 1250], + ["12.50", 1250], + ["₺1.250,50", 125050], + ["1.250,50 TL", 125050], + [" 1 250,50 ", 125050], + ["-12,30", -1230], + ["−12,30", -1230], + ["+7", 700], + ["0", 0], + ["-0", 0], + ])("%s → %i", (input, expected) => { + expect(parseMoney(input)).toBe(expected); + }); + + it.each([ + "", + "abc", + "1,2,3", + "12,345", + "1.25,00", + "1.2345", + "12.345.6", + "1,5e3", + "--5", + "100000000000,01", + ])("geçersiz: %s", (input) => { + expect(parseMoney(input)).toBeNull(); + }); +}); + +describe("formatMoneyInput", () => { + it("Türkçe biçimde simgesiz yazar ve parseMoney ile geri döner", () => { + for (const v of [0, 1, 99, 100, 125050, 1234567890, -1230]) { + expect(parseMoney(formatMoneyInput(v))).toBe(v); + } + expect(formatMoneyInput(125050)).toBe("1.250,50"); + expect(formatMoneyInput(-5)).toBe("-0,05"); + }); +}); + +describe("allocate", () => { + it("toplam her zaman korunur (kuruş kaybı yok)", () => { + for (const total of [1, 100, 10000, 99999, 120000, -10001]) { + for (const weights of [[1], [1, 1, 1], [800, 250, 150], [1, 2, 3, 4, 5, 6, 7], [0, 1]]) { + const parts = allocate(total, weights); + expect(parts.reduce((a, b) => a + b, 0)).toBe(total); + expect(parts).toHaveLength(weights.length); + } + } + }); + + it("ağırlıklara orantılıdır ve deterministiktir", () => { + expect(allocate(120000, [800, 250, 150])).toEqual([80000, 25000, 15000]); + expect(allocate(100, [1, 1, 1])).toEqual([34, 33, 33]); + expect(allocate(-100, [1, 1, 1])).toEqual([-34, -33, -33]); + expect(allocate(5, [0, 1])).toEqual([0, 5]); + }); + + it("geçersiz girdiyi reddeder", () => { + expect(() => allocate(100, [])).toThrow(RangeError); + expect(() => allocate(100, [0, 0])).toThrow(RangeError); + expect(() => allocate(100, [-1, 2])).toThrow(RangeError); + expect(() => allocate(1.5, [1])).toThrow(TypeError); + }); +}); + +describe("splitEvenly", () => { + it("9 taksit: 1.000,00 TL", () => { + const parts = splitEvenly(100000, 9); + expect(sumMoney(parts)).toBe(100000); + expect(parts[0]).toBe(11112); + expect(parts.slice(1).every((p) => p === 11111)).toBe(true); + }); +}); diff --git a/src/shared/money.ts b/src/shared/money.ts new file mode 100644 index 0000000..a963fb2 --- /dev/null +++ b/src/shared/money.ts @@ -0,0 +1,119 @@ +/** + * Para birimi yardımcıları — tutarlar HER ZAMAN tamsayı kuruştur (`amountMinor`). + * 125,50 TL → 12550. Float ile hesap yapılmaz; yuvarlama yalnızca gösterimde. + * + * Ayrıştırma kuralları (tr-TR): + * - Virgül varsa ondalık ayırıcıdır; noktalar ve boşluklar binlik ayırıcıdır: "1.250,50" → 125050. + * - Virgül yoksa ve noktalar geçerli binlik gruplarıysa (1.250 / 12.345.678) binlik ayırıcıdır. + * - Virgül yoksa ve tek nokta 1–2 ondalık içeriyorsa ondalıktır: "12.5" → 1250 (telefon klavyeleri). + * - En fazla 2 ondalık; "₺", "TL", boşluklar ve baştaki +/− kabul edilir. Belirsiz girdi → null. + */ + +export const MAX_AMOUNT_MINOR = 100_000_000_000_00; // 100 milyar TL üst sınırı (güvenli tamsayı içinde) + +const THOUSANDS_GROUPED = /^\d{1,3}(\.\d{3})+$/; + +export function parseMoney(input: string): number | null { + let s = input + .trim() + .replace(/\s| | /g, "") + .replace(/₺|TL$/i, ""); + if (!s) return null; + let sign = 1; + if (s.startsWith("-") || s.startsWith("−")) { + sign = -1; + s = s.slice(1); + } else if (s.startsWith("+")) { + s = s.slice(1); + } + s = s.replace(/₺/g, ""); + + let intPart: string; + let fracPart = ""; + if (s.includes(",")) { + const parts = s.split(","); + if (parts.length !== 2) return null; + [intPart, fracPart] = parts as [string, string]; + if (intPart.includes(".")) { + if (!THOUSANDS_GROUPED.test(intPart)) return null; + intPart = intPart.replace(/\./g, ""); + } + } else if (s.includes(".")) { + if (THOUSANDS_GROUPED.test(s)) { + intPart = s.replace(/\./g, ""); + } else { + const parts = s.split("."); + if (parts.length !== 2 || parts[1]!.length > 2) return null; + [intPart, fracPart] = parts as [string, string]; + } + } else { + intPart = s; + } + + if (intPart === "") intPart = "0"; + if (!/^\d+$/.test(intPart) || !/^\d{0,2}$/.test(fracPart)) return null; + const minor = Number(intPart) * 100 + Number(fracPart.padEnd(2, "0")); + if (!Number.isSafeInteger(minor) || minor > MAX_AMOUNT_MINOR) return null; + return sign * minor === 0 ? 0 : sign * minor; +} + +/** Form alanı için: 125050 → "1.250,50" (simgesiz). */ +export function formatMoneyInput(amountMinor: number): string { + assertMinor(amountMinor); + const sign = amountMinor < 0 ? "-" : ""; + const abs = Math.abs(amountMinor); + const int = Math.trunc(abs / 100) + .toString() + .replace(/\B(?=(\d{3})+(?!\d))/g, "."); + const frac = (abs % 100).toString().padStart(2, "0"); + return `${sign}${int},${frac}`; +} + +export function assertMinor(value: number): void { + if (!Number.isSafeInteger(value)) throw new TypeError(`Kuruş tutarı tamsayı olmalı: ${value}`); +} + +/** Kuruş tutarlarını toplar (tamsayı garantili). */ +export function sumMoney(values: readonly number[]): number { + let total = 0; + for (const v of values) { + assertMinor(v); + total += v; + } + assertMinor(total); + return total; +} + +/** + * Oransal bölme (en büyük kalan yöntemi): kuruş kaybı olmadan `totalMinor`'u ağırlıklara böler. + * Parçaların toplamı her zaman tam olarak `totalMinor`'dur. Taksit ve bölünmüş işlem için. + * Eşit kalanlarda önceki parça önceliklidir (deterministik). + */ +export function allocate(totalMinor: number, weights: readonly number[]): number[] { + assertMinor(totalMinor); + if (weights.length === 0) throw new RangeError("En az bir ağırlık gerekli"); + if (weights.some((w) => !Number.isFinite(w) || w < 0)) + throw new RangeError("Ağırlıklar negatif olamaz"); + const weightSum = weights.reduce((a, b) => a + b, 0); + if (weightSum <= 0) throw new RangeError("Ağırlıkların toplamı sıfırdan büyük olmalı"); + + const sign = totalMinor < 0 ? -1 : 1; + const abs = Math.abs(totalMinor); + const raw = weights.map((w) => (abs * w) / weightSum); + const parts = raw.map((r) => Math.floor(r)); + let remainder = abs - parts.reduce((a, b) => a + b, 0); + const order = raw + .map((r, i) => ({ i, frac: r - Math.floor(r) })) + .sort((a, b) => b.frac - a.frac || a.i - b.i); + for (let k = 0; remainder > 0; k = (k + 1) % order.length, remainder--) parts[order[k]!.i]! += 1; + return parts.map((p) => (p === 0 ? 0 : sign * p)); +} + +/** Eşit bölme: 100,00 TL / 3 → [3334, 3333, 3333]. */ +export function splitEvenly(totalMinor: number, count: number): number[] { + if (!Number.isInteger(count) || count < 1) throw new RangeError("Parça sayısı en az 1 olmalı"); + return allocate( + totalMinor, + Array.from({ length: count }, () => 1), + ); +} diff --git a/src/shared/quantity.test.ts b/src/shared/quantity.test.ts new file mode 100644 index 0000000..5817912 --- /dev/null +++ b/src/shared/quantity.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { convertQuantity, formatQuantity, parseQuantity } from "./quantity"; + +describe("parseQuantity", () => { + it.each([ + ["1,5", 1500], + ["1.5", 1500], + ["2", 2000], + ["0,125", 125], + ["5", 5000], + [" 10,25 ", 10250], + ])("%s → %i", (input, expected) => { + expect(parseQuantity(input)).toBe(expected); + }); + + it.each(["", "-1", "1,2345", "1,2,3", "abc", "1e3"])("geçersiz: %s", (input) => { + expect(parseQuantity(input)).toBeNull(); + }); +}); + +describe("formatQuantity", () => { + it("gereksiz sıfırları atar, birim ekler", () => { + expect(formatQuantity(1500, "kg")).toBe("1,5 kg"); + expect(formatQuantity(5000, "gr")).toBe("5 gr"); + expect(formatQuantity(125)).toBe("0,125"); + expect(formatQuantity(1234500)).toBe("1.234,5"); + expect(formatQuantity(-250)).toBe("-0,25"); + }); +}); + +describe("convertQuantity", () => { + it("aynı boyutta dönüştürür", () => { + expect(convertQuantity(1500, "kg", "g")).toBe(1500000); // 1,5 kg = 1500 g + expect(convertQuantity(500000, "g", "kg")).toBe(500); // 500 g = 0,5 kg + expect(convertQuantity(2000, "lt", "ml")).toBe(2000000); + }); + it("uyumsuz birimde hata verir", () => { + expect(() => convertQuantity(1000, "kg", "lt")).toThrow(RangeError); + }); +}); diff --git a/src/shared/quantity.ts b/src/shared/quantity.ts new file mode 100644 index 0000000..bafbaac --- /dev/null +++ b/src/shared/quantity.ts @@ -0,0 +1,51 @@ +/** + * Miktar yardımcıları (stok, gram altın, döviz, adet). + * + * KURAL: Tüm miktarlar birimin BİNDE BİRİ cinsinden tamsayı saklanır (QUANTITY_SCALE = 1000): + * 1,5 kg → 1500 · 5 gr altın → 5000 · 2 adet çeyrek → 2000 · 0,125 lt → 125 · 100 USD → 100000 + * Böylece 3 ondalık hassasiyet sağlanır, toplama/çıkarma tamsayıdır. Birimler arası dönüşüm + * (kg↔g, lt↔ml) aynı boyuttaki birimler için tanımlıdır; özel birimler ("yıkama", "tablet") + * ürün bazında dönüşüm oranıyla (Faz 12) çevrilir. + */ + +export const QUANTITY_SCALE = 1000; + +export const UNITS = { + g: { label: "g", dimension: "mass", factor: 1 }, + kg: { label: "kg", dimension: "mass", factor: 1000 }, + ml: { label: "ml", dimension: "volume", factor: 1 }, + lt: { label: "lt", dimension: "volume", factor: 1000 }, + adet: { label: "adet", dimension: "count", factor: 1 }, +} as const; + +export type Unit = keyof typeof UNITS; + +/** "1,5" / "1.5" / "2" / "0,125" → binde bir tamsayı. En fazla 3 ondalık; geçersizse null. */ +export function parseQuantity(input: string): number | null { + const s = input.trim().replace(/\s/g, ""); + const m = /^(\d+)(?:[.,](\d{1,3}))?$/.exec(s); + if (!m) return null; + const value = Number(m[1]) * QUANTITY_SCALE + Number((m[2] ?? "").padEnd(3, "0")); + return Number.isSafeInteger(value) ? value : null; +} + +/** 1500 → "1,5"; 5000 → "5"; 125 → "0,125" (gereksiz sıfırlar atılır). */ +export function formatQuantity(quantity: number, unit?: string): string { + if (!Number.isSafeInteger(quantity)) throw new TypeError(`Miktar tamsayı olmalı: ${quantity}`); + const sign = quantity < 0 ? "-" : ""; + const abs = Math.abs(quantity); + const int = Math.trunc(abs / QUANTITY_SCALE).toLocaleString("tr-TR"); + const frac = (abs % QUANTITY_SCALE).toString().padStart(3, "0").replace(/0+$/, ""); + const text = `${sign}${int}${frac ? `,${frac}` : ""}`; + return unit ? `${text} ${unit}` : text; +} + +/** Aynı boyuttaki birimler arasında dönüşüm (kg → g vb.). Uyumsuz birimde hata. */ +export function convertQuantity(quantity: number, from: Unit, to: Unit): number { + const a = UNITS[from]; + const b = UNITS[to]; + if (a.dimension !== b.dimension) throw new RangeError(`${from} → ${to} dönüştürülemez`); + const result = (quantity * a.factor) / b.factor; + // Hassasiyet kaybı olursa (ör. 1 ml → lt = 0,001 lt = 1) yuvarlanır; 3 ondalık sınırı. + return Math.round(result); +} diff --git a/src/shared/schemas/account.ts b/src/shared/schemas/account.ts new file mode 100644 index 0000000..9dc52b3 --- /dev/null +++ b/src/shared/schemas/account.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; +import { ACCOUNT_TYPES, DUE_RULE_KINDS, MEAL_CARD_PROVIDERS } from "../accounts"; +import { ICON_KEYS, SWATCHES } from "../appearance"; +import { parseMoney } from "../money"; + +const v = tr.validation; + +/** Türkçe para girişi ("1.250,50") → kuruş. Boş → 0 (opsiyonel alanlarda). */ +export const moneyInputSchema = z + .string() + .trim() + .transform((s, ctx) => { + if (s === "") return 0; + const minor = parseMoney(s); + if (minor === null) { + ctx.addIssue({ code: "custom", message: v.money }); + return z.NEVER; + } + return minor; + }); + +/** Pozitif (≥ 0) para girişi. */ +export const nonNegativeMoneySchema = moneyInputSchema.refine((n) => n >= 0, v.moneyNonNegative); + +const day = (max: number) => z.coerce.number().int().min(1).max(max); +const objectIdOrEmpty = z + .string() + .regex(/^([a-f0-9]{24})?$/i) + .transform((s) => (s === "" ? null : s)); + +/** Yüzde girişi ("4,25") → baz puan (425). Boş → null. */ +const percentToBp = z + .string() + .trim() + .transform((s, ctx) => { + if (s === "") return null; + const minor = parseMoney(s); // aynı ondalık kuralları: 2 hane + if (minor === null || minor < 0 || minor > 100_00) { + ctx.addIssue({ code: "custom", message: v.percent }); + return z.NEVER; + } + return minor; + }); + +export const accountFormSchema = z + .object({ + name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax), + type: z.enum(ACCOUNT_TYPES), + institution: z.string().trim().max(80, v.nameMax), + mealCardProvider: z.enum(MEAL_CARD_PROVIDERS).nullable(), + /** Kart için "açılış borcu" pozitif girilir; sunucu negatife çevirir. */ + openingBalance: moneyInputSchema, + /** yyyy-MM-dd (İstanbul günü) */ + openingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date), + color: z.enum(SWATCHES), + icon: z.enum(ICON_KEYS), + ownerUserId: objectIdOrEmpty, + isPrivate: z.boolean(), + overdraftEnabled: z.boolean(), + overdraftLimit: nonNegativeMoneySchema, + overdraftMonthlyRate: percentToBp, + overdraftStatementDay: z + .union([z.literal(""), day(31)]) + .transform((d) => (d === "" ? null : d)), + cardLimit: nonNegativeMoneySchema, + cardStatementDay: day(31), + cardDueKind: z.enum(DUE_RULE_KINDS), + cardDueValue: day(31), + cardPaymentAccountId: objectIdOrEmpty, + }) + .superRefine((a, ctx) => { + if (a.type === "meal_card" && !a.mealCardProvider) { + ctx.addIssue({ code: "custom", path: ["mealCardProvider"], message: v.selectProvider }); + } + if (a.isPrivate && !a.ownerUserId) { + ctx.addIssue({ code: "custom", path: ["ownerUserId"], message: v.privateNeedsOwner }); + } + if (a.type === "credit_card" && a.cardDueKind === "daysAfter" && a.cardDueValue > 30) { + ctx.addIssue({ code: "custom", path: ["cardDueValue"], message: v.dueDays }); + } + // Yalnızca banka hesabı (KMH ile) negatif açılabilir; kart borcu pozitif girilir. + if (a.openingBalance < 0 && a.type !== "bank") { + ctx.addIssue({ code: "custom", path: ["openingBalance"], message: v.moneyNonNegative }); + } + }); + +export type AccountFormInput = z.input; +export type AccountForm = z.output; diff --git a/src/shared/schemas/category.ts b/src/shared/schemas/category.ts new file mode 100644 index 0000000..b300718 --- /dev/null +++ b/src/shared/schemas/category.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; +import { ICON_KEYS, SWATCHES } from "../appearance"; + +export const CATEGORY_KINDS = ["expense", "income"] as const; +export type CategoryKind = (typeof CATEGORY_KINDS)[number]; + +export const categoryFormSchema = z.object({ + kind: z.enum(CATEGORY_KINDS), + name: z.string().trim().min(1, tr.common.required).max(80, tr.validation.nameMax), + parentId: z + .string() + .regex(/^([a-f0-9]{24})?$/i) + .transform((s) => (s === "" ? null : s)), + icon: z.enum(ICON_KEYS), + color: z.enum(SWATCHES), +}); + +export type CategoryFormInput = z.input; +export type CategoryForm = z.output; diff --git a/src/test/seeded-db.ts b/src/test/seeded-db.ts new file mode 100644 index 0000000..025a236 --- /dev/null +++ b/src/test/seeded-db.ts @@ -0,0 +1,27 @@ +import mongoose from "mongoose"; +import { expect } from "vitest"; +import { getDb, getMongoClient } from "@/lib/db/mongo"; +import { HouseholdModel } from "@/models/household"; +import { runSeed } from "../../scripts/seed/run"; + +/** Entegrasyon testleri için: test veritabanını temizler, seed'i çalıştırır, kimlikleri döner. */ +export async function setupSeededDb() { + expect(getDb().databaseName).toBe("butcem_test"); + await getDb().dropDatabase(); + await runSeed({ log: () => {} }); + const users = new Map(); + for (const u of await getDb().collection("user").find().toArray()) + users.set(String(u.username), String(u._id)); + const households = new Map(); + for (const h of await HouseholdModel.find().lean()) households.set(h.name, String(h._id)); + return { + user: (username: string) => users.get(username)!, + household: (name: string) => households.get(name)!, + }; +} + +export async function teardownDb() { + await getDb().dropDatabase(); + await mongoose.disconnect(); + await getMongoClient().close(); +}