From afc6da1606f6d1af1fa2c266d3cef73510cf620a Mon Sep 17 00:00:00 2001 From: suphicakir Date: Sat, 7 Nov 2026 04:51:35 +0300 Subject: [PATCH] =?UTF-8?q?feat(kakeibo):=20d=C3=B6rt=20harcama=20grubu,?= =?UTF-8?q?=20banka=20maliyeti=20ve=20geri=20al=C4=B1nabilir=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared/kakeibo: grup devralma, banka maliyeti, kayıt bazında grup, grup özeti (saf, testli) - kategori ve işlem/kalem modellerine kakeiboGroup ve bankCost; form şemaları - migration 016 varsayılanları doldurur; npm run migrate -- --down ile geri alma - Kakeibo rapor servisi: tutarlar bütçeyle aynı kaynaktan, grup değiştirmeler taşınır Co-Authored-By: Claude Opus 5.5 --- scripts/migrate.ts | 12 +- scripts/migrations/016-kakeibo-groups.ts | 43 +++++ scripts/migrations/index.ts | 22 +++ scripts/migrations/types.ts | 6 +- src/features/categories/service.int.test.ts | 2 + src/features/categories/service.ts | 65 +++++++- src/features/reports/kakeibo.int.test.ts | 141 ++++++++++++++++ src/features/reports/kakeibo.ts | 138 +++++++++++++++ src/features/reports/service.ts | 4 +- src/features/transactions/service.ts | 55 +++++- src/i18n/tr.ts | 46 +++++ src/models/category.ts | 8 + src/models/transaction.ts | 5 + src/shared/kakeibo.test.ts | 105 ++++++++++++ src/shared/kakeibo.ts | 175 ++++++++++++++++++++ src/shared/schemas/category.ts | 8 + src/shared/schemas/transaction.ts | 9 + 17 files changed, 831 insertions(+), 13 deletions(-) create mode 100644 scripts/migrations/016-kakeibo-groups.ts create mode 100644 src/features/reports/kakeibo.int.test.ts create mode 100644 src/features/reports/kakeibo.ts create mode 100644 src/shared/kakeibo.test.ts create mode 100644 src/shared/kakeibo.ts diff --git a/scripts/migrate.ts b/scripts/migrate.ts index 0edf58c..57ed68e 100644 --- a/scripts/migrate.ts +++ b/scripts/migrate.ts @@ -4,9 +4,17 @@ */ import mongoose from "mongoose"; import { getMongoClient } from "@/lib/db/mongo"; -import { runMigrations } from "./migrations"; +import { revertMigration, runMigrations } from "./migrations"; -runMigrations() +// npm run migrate → bekleyen migration'lar +// npm run migrate -- --down → bir migration'ı geri al +const downIndex = process.argv.indexOf("--down"); +const task = + downIndex >= 0 + ? revertMigration(process.argv[downIndex + 1] ?? "") + : runMigrations().then(() => {}); + +task .catch((err: unknown) => { console.error(err instanceof Error ? err.message : err); process.exitCode = 1; diff --git a/scripts/migrations/016-kakeibo-groups.ts b/scripts/migrations/016-kakeibo-groups.ts new file mode 100644 index 0000000..4c6a593 --- /dev/null +++ b/scripts/migrations/016-kakeibo-groups.ts @@ -0,0 +1,43 @@ +import { defaultKakeibo } from "@/features/categories/service"; +import { CategoryModel } from "@/models/category"; +import { TransactionModel } from "@/models/transaction"; +import type { Migration } from "./types"; + +/** + * Kakeibo dört grup (Faz 16): gider kategorilerine `kakeiboGroup` ve `bankCost`. Varsayılan + * kategoriler beceri eşlemesine göre doldurulur; kullanıcının kategorileri grupsuz kalır (arayüz + * "grup seç" uyarısı gösterir). Alanı zaten olan kategoriye dokunulmaz. Hiçbir kategori silinmez. + * Geri alma: alanlar kaldırılır (npm run migrate -- --down 016-kakeibo-groups). + */ +export const migration: Migration = { + name: "016-kakeibo-groups", + description: "gider kategorilerine Kakeibo grubu ve banka maliyeti; varsayılanlar dolduruldu", + async up() { + const defaults = await CategoryModel.find({ + kind: "expense", + defaultKey: { $type: "string" }, + kakeiboGroup: { $exists: false }, + }) + .select({ defaultKey: 1 }) + .lean<{ _id: unknown; defaultKey: string }[]>(); + const ops = defaults.map((c) => ({ + updateOne: { filter: { _id: c._id }, update: { $set: defaultKakeibo(c.defaultKey) } }, + })); + if (ops.length) await CategoryModel.bulkWrite(ops); + await CategoryModel.updateMany( + { kakeiboGroup: { $exists: false } }, + { $set: { kakeiboGroup: null, bankCost: false } }, + ); + }, + async down() { + await CategoryModel.updateMany({}, { $unset: { kakeiboGroup: "", bankCost: "" } }); + await TransactionModel.updateMany( + { kakeiboGroup: { $exists: true } }, + { $unset: { kakeiboGroup: "" } }, + ); + await TransactionModel.updateMany( + { "splits.kakeiboGroup": { $exists: true } }, + { $unset: { "splits.$[].kakeiboGroup": "" } }, + ); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index dfe9f06..182bb00 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -15,6 +15,7 @@ import { migration as m012 } from "./012-stock-counts"; import { migration as m013 } from "./013-stores-products"; import { migration as m014 } from "./014-product-prices"; import { migration as m015 } from "./015-shopping-lists"; +import { migration as m016 } from "./016-kakeibo-groups"; import type { Migration } from "./types"; /** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */ @@ -34,6 +35,7 @@ export const MIGRATIONS: Migration[] = [ m013, m014, m015, + m016, ]; const COLLECTION = "migrations"; @@ -63,3 +65,23 @@ export async function runMigrations(log: (line: string) => void = console.log): if (!ran.length) log("• Bekleyen migration yok."); return ran; } + +/** + * Bir migration'ı geri alır (yalnızca `down` tanımlıysa) ve kaydını siler; bir sonraki + * `runMigrations` onu yeniden uygular. Kullanım: npm run migrate -- --down 016-kakeibo-groups + */ +export async function revertMigration( + name: string, + log: (line: string) => void = console.log, +): Promise { + const m = MIGRATIONS.find((x) => x.name === name); + if (!m) throw new Error(`Migration bulunamadı: ${name}`); + if (!m.down) throw new Error(`${name} geri alınamaz (down tanımlı değil).`); + const db = getDb(); + await connectMongoose(); + const col = db.collection<{ name: string }>(COLLECTION); + if (!(await col.findOne({ name }))) throw new Error(`${name} uygulanmamış.`); + await m.down(db); + await col.deleteOne({ name }); + log(`↩ Geri alındı: ${name}`); +} diff --git a/scripts/migrations/types.ts b/scripts/migrations/types.ts index 595e5e8..781e067 100644 --- a/scripts/migrations/types.ts +++ b/scripts/migrations/types.ts @@ -1,8 +1,12 @@ import type { Db } from "mongodb"; -/** Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). Geri alma yok; ileri yönlü. */ +/** + * Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). İleri yönlü; Faz 16'dan + * itibaren veri değiştirenler `down` ile geri alınabilir (npm run migrate -- --down ). + */ export type Migration = { name: string; description: string; up: (db: Db) => Promise; + down?: (db: Db) => Promise; }; diff --git a/src/features/categories/service.int.test.ts b/src/features/categories/service.int.test.ts index 16b73bd..3b83073 100644 --- a/src/features/categories/service.int.test.ts +++ b/src/features/categories/service.int.test.ts @@ -31,6 +31,8 @@ const form = (over: Partial[2]> = {}) => ({ parentId: null, icon: "tag" as const, color: "blue" as const, + kakeiboGroup: null, + bankCost: false, ...over, }); diff --git a/src/features/categories/service.ts b/src/features/categories/service.ts index 4af3981..2f0d68b 100644 --- a/src/features/categories/service.ts +++ b/src/features/categories/service.ts @@ -9,6 +9,13 @@ 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 { + DEFAULT_BANK_COST_KEYS, + DEFAULT_KAKEIBO_GROUPS, + resolveKakeibo, + type KakeiboGroup, + type ResolvedKakeibo, +} from "@/shared/kakeibo"; import { foldTr } from "@/shared/search"; import { DEFAULT_CATEGORIES } from "./defaults"; import { @@ -26,7 +33,16 @@ import { const t = tr.categories; -export type CategoryView = FlatCategory & { icon: IconKey; color: Swatch; isDefault: boolean }; +export type CategoryView = FlatCategory & { + icon: IconKey; + color: Swatch; + isDefault: boolean; + /** Kendi Kakeibo grubu (null = üstten devral). */ + kakeiboGroup: KakeiboGroup | null; + bankCost: boolean; + /** Etkin grup ve banka maliyeti (devralma dahil). */ + kakeibo: ResolvedKakeibo; +}; type Lean = { _id: Types.ObjectId; @@ -39,9 +55,26 @@ type Lean = { color: string; defaultKey: string | null; ancestors: Types.ObjectId[]; + kakeiboGroup?: KakeiboGroup | null; + bankCost?: boolean; }; -const toView = (c: Lean): CategoryView => ({ +const NONE: ResolvedKakeibo = { group: null, inherited: false, bankCost: false }; + +/** Görünümler: etkin Kakeibo grubu tüm kategoriler (arşivliler dahil) üzerinden çözülür. */ +function toViews(rows: Lean[], all: Lean[] = rows): CategoryView[] { + const resolved = resolveKakeibo( + all.map((c) => ({ + id: String(c._id), + parentId: c.parentId ? String(c.parentId) : null, + kakeiboGroup: c.kakeiboGroup ?? null, + bankCost: Boolean(c.bankCost), + })), + ); + return rows.map((c) => ({ ...toView(c), kakeibo: resolved.get(String(c._id)) ?? NONE })); +} + +const toView = (c: Lean): Omit & { kakeibo?: ResolvedKakeibo } => ({ id: String(c._id), kind: c.kind, name: c.name, @@ -51,8 +84,16 @@ const toView = (c: Lean): CategoryView => ({ icon: c.icon as IconKey, color: c.color as Swatch, isDefault: Boolean(c.defaultKey), + kakeiboGroup: c.kakeiboGroup ?? null, + bankCost: Boolean(c.bankCost), }); +/** Kakeibo alanları yalnızca gider kategorisinde; gelirde temizlenir. */ +const kakeiboFields = (input: CategoryForm) => + input.kind === "expense" + ? { kakeiboGroup: input.kakeiboGroup, bankCost: input.bankCost } + : { kakeiboGroup: null, bankCost: false }; + const violationMessage = (v: MoveViolation) => t.violations[v]; export async function listCategories( @@ -64,9 +105,9 @@ export async function listCategories( 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); + const all = await CategoryModel.find(filter).lean(); + const rows = opts.includeArchived ? all : all.filter((c) => !c.archivedAt); + return toViews(rows, all); } async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) { @@ -86,6 +127,17 @@ function hasSiblingNamed(all: Lean[], parentId: string | null, name: string, exc ); } +/** Varsayılan kategorinin Kakeibo grubu ve banka maliyeti işareti (Faz 16). */ +export function defaultKakeibo(key: string): { + kakeiboGroup: KakeiboGroup | null; + bankCost: boolean; +} { + return { + kakeiboGroup: DEFAULT_KAKEIBO_GROUPS[key] ?? null, + bankCost: (DEFAULT_BANK_COST_KEYS as readonly string[]).includes(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. @@ -117,6 +169,7 @@ export async function installDefaultCategories( color: def.color, order, defaultKey: def.key, + ...(kind === "expense" ? defaultKakeibo(def.key) : {}), createdBy: actor, updatedBy: actor, }, @@ -164,6 +217,7 @@ export async function createCategory( ancestors: parent ? [...parent.ancestors, parent._id] : [], icon: input.icon, color: input.color, + ...kakeiboFields(input), order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0, createdBy: actor, updatedBy: actor, @@ -209,6 +263,7 @@ export async function updateCategory( nameKey: foldTr(input.name), icon: input.icon, color: input.color, + ...kakeiboFields(input), updatedBy: actor, }; if (moving) { diff --git a/src/features/reports/kakeibo.int.test.ts b/src/features/reports/kakeibo.int.test.ts new file mode 100644 index 0000000..2c32889 --- /dev/null +++ b/src/features/reports/kakeibo.int.test.ts @@ -0,0 +1,141 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getBudget } from "@/features/budgets/service"; +import { listCategories, updateCategory } from "@/features/categories/service"; +import { + createTransaction, + getTransactionFormOptions, + setTransactionDeleted, +} from "@/features/transactions/service"; +import { AccountModel } from "@/models/account"; +import { CategoryModel } from "@/models/category"; +import { todayIstanbul } from "@/shared/dates"; +import { transactionFormSchema } from "@/shared/schemas/transaction"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { getKakeiboReport } from "./kakeibo"; + +let ids: Awaited>; +let cakir = ""; +let ayse = ""; +let fatma = ""; + +const catId = (defaultKey: string) => + CategoryModel.findOne({ householdId: cakir, defaultKey }) + .lean() + .then((c) => String(c!._id)); + +beforeAll(async () => { + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + ayse = ids.user("ayse"); + fatma = ids.user("fatma"); +}); +afterAll(teardownDb); + +describe("varsayılan gruplar", () => { + it("varsayılan kategoriler gruplu; alt kategoriler devralır; banka maliyeti işaretli", async () => { + const cats = (await listCategories(ayse, cakir, { kind: "expense" }))!; + const by = (name: string) => cats.find((c) => c.name === name)!; + expect(by("Market").kakeibo).toEqual({ group: "ZORUNLU", inherited: false, bankCost: false }); + expect(by("Gıda").kakeibo).toEqual({ group: "ZORUNLU", inherited: true, bankCost: false }); + expect(by("Kişisel bakım").kakeibo.group).toBe("ISTEK"); + expect(by("Dışarıda yemek").kakeibo.group).toBe("ISTEK"); + expect(by("Eğitim").kakeibo.group).toBe("KULTUR"); + expect(by("Faiz").kakeibo.bankCost).toBe(true); + expect(by("Diğer").kakeibo.group).toBeNull(); + const opts = (await getTransactionFormOptions(ayse, cakir))!; + expect(opts.categoryKakeibo[by("Gıda").id]).toEqual({ group: "ZORUNLU", bankCost: false }); + }); +}); + +describe("Kakeibo raporu", () => { + it("harcama + banka maliyeti = bütçenin gerçekleşeni; başka hane göremez", async () => { + const r = (await getKakeiboReport(ayse, cakir))!; + const budget = (await getBudget(ayse, cakir))!; + const s = r.now.summary; + expect(s.spendingMinor + s.bankCostMinor).toBe(budget.totals.actualMinor); + const classSum = Object.values(r.now.byClass) + .flat() + .reduce((a, x) => a + x.amountMinor, 0); + expect(classSum).toBe(budget.totals.actualMinor); + expect(await getKakeiboReport(fatma, cakir)).toBeNull(); + }); + + it("işlem bazında grup değiştirme tutarı taşır; banka maliyeti harcamaya girmez", async () => { + const before = (await getKakeiboReport(ayse, cakir))!.now.summary; + const account = String((await AccountModel.findOne({ name: "Ziraat Maaş" }).lean())!._id); + const form = (over: Record) => + transactionFormSchema.parse({ + type: "expense", + amount: "100", + date: todayIstanbul(), + accountId: account, + installments: 1, + ...over, + }); + const gida = await catId("market.gida"); + const faiz = await catId("banka.faiz"); + // Market alışverişi ama amaç "istek" (ör. doğum günü pastası). + const a = await createTransaction( + ayse, + cakir, + form({ categoryId: gida, kakeiboGroup: "ISTEK" }), + ); + // Bölünmüş: 60 ZORUNLU (kategoriden) + 40 KÜLTÜR (değiştirilmiş). + const b = await createTransaction( + ayse, + cakir, + form({ + categoryId: "", + splits: [ + { categoryId: gida, amount: "60", note: "" }, + { categoryId: gida, amount: "40", note: "", kakeiboGroup: "KULTUR" }, + ], + }), + ); + const c = await createTransaction(ayse, cakir, form({ categoryId: faiz, amount: "25" })); + expect([a.ok, b.ok, c.ok]).toEqual([true, true, true]); + + const after = (await getKakeiboReport(ayse, cakir))!.now.summary; + expect(after.groups.ISTEK - before.groups.ISTEK).toBe(100_00); + expect(after.groups.ZORUNLU - before.groups.ZORUNLU).toBe(60_00); + expect(after.groups.KULTUR - before.groups.KULTUR).toBe(40_00); + expect(after.bankCostMinor - before.bankCostMinor).toBe(25_00); + expect(after.spendingMinor - before.spendingMinor).toBe(200_00); + + for (const r of [a, b, c]) if (r.ok) await setTransactionDeleted(ayse, cakir, r.data.id, true); + }); + + it("kategoriye grup atanınca grupsuzdan çıkar", async () => { + const diger = await catId("diger-gider"); + const cats = (await listCategories(ayse, cakir, { kind: "expense" }))!; + const d = cats.find((c) => c.id === diger)!; + const r = await updateCategory(ayse, cakir, diger, { + kind: "expense", + name: d.name, + parentId: null, + icon: d.icon, + color: d.color, + kakeiboGroup: "BEKLENMEYEN", + bankCost: false, + }); + expect(r.ok).toBe(true); + const after = (await listCategories(ayse, cakir, { kind: "expense" }))!.find( + (c) => c.id === diger, + )!; + expect(after.kakeibo.group).toBe("BEKLENMEYEN"); + }); +}); + +describe("migration 016", () => { + it("geri alınır (alanlar kalkar) ve yeniden uygulanınca varsayılanlar dolar", async () => { + const { revertMigration, runMigrations } = await import("../../../scripts/migrations"); + const log = () => {}; + await revertMigration("016-kakeibo-groups", log); + expect(await CategoryModel.countDocuments({ kakeiboGroup: { $exists: true } })).toBe(0); + expect(await runMigrations(log)).toEqual(["016-kakeibo-groups"]); + const gida = await CategoryModel.findOne({ householdId: cakir, defaultKey: "market" }).lean(); + expect(gida!.kakeiboGroup).toBe("ZORUNLU"); + const banka = await CategoryModel.findOne({ householdId: cakir, defaultKey: "banka" }).lean(); + expect(banka!.bankCost).toBe(true); + }); +}); diff --git a/src/features/reports/kakeibo.ts b/src/features/reports/kakeibo.ts new file mode 100644 index 0000000..5966115 --- /dev/null +++ b/src/features/reports/kakeibo.ts @@ -0,0 +1,138 @@ +import "server-only"; +import type { Types } from "mongoose"; +import { tr } from "@/i18n/tr"; +import { TransactionModel } from "@/models/transaction"; +import { budgetPeriod, shiftMonth } from "@/shared/budget"; +import { istanbulDayStart } from "@/shared/dates"; +import { + classifyAmounts, + isKakeiboGroup, + KAKEIBO_GROUPS, + summarizeGroups, + type GroupSummary, + type KakeiboGroup, + type SpendingClass, +} from "@/shared/kakeibo"; +import { changePct } from "@/shared/reports"; +import { actualByCategory, base, monthInfo, ORPHAN, type Base, type MonthInfo } from "./service"; + +/** + * Kakeibo grup raporu (Faz 16). Kategori tutarları bütçeyle aynı kaynaktan (gerçekleşmiş + * giderler, bölünmüş kalemler, taksit modu, gizli hesap ayarı); işlem/kalem bazında grubu + * değiştirilen tutarlar seçilen gruba taşınır. Banka maliyeti ayrı, grupsuzlar listelenir. + */ + +const DAY_MS = 86_400_000; + +export type KakeiboCategoryRow = { id: string; path: string; amountMinor: number }; + +export type KakeiboMonth = { + summary: GroupSummary; + /** Sınıf başına kategori kırılımı (tutara göre büyükten küçüğe). */ + byClass: Record; +}; + +export type KakeiboReport = MonthInfo & { + current: boolean; + previous: string; + now: KakeiboMonth; + prev: KakeiboMonth; + /** Grup başına önceki aya göre değişim (tutar ve yüzde). */ + change: Record; + includesHidden: boolean; +}; + +/** Dönemde grubu işlem/kalem bazında değiştirilmiş gider tutarları (kategori × grup). */ +async function overrides(b: Base, month: string) { + const { from, to } = budgetPeriod(month, b.s.startDay); + const match: Record = { + householdId: b.ctx.m.householdId, + type: "expense", + status: "posted", + deletedAt: null, + date: { + $gte: istanbulDayStart(from)!, + $lt: new Date(istanbulDayStart(to)!.getTime() + DAY_MS), + }, + $or: [{ kakeiboGroup: { $type: "string" } }, { "splits.kakeiboGroup": { $type: "string" } }], + }; + if (!b.s.includePrivate && b.ctx.hiddenAccountIds.length) + match.accountId = { $nin: b.ctx.hiddenAccountIds }; + // Tamamı alış ayına modunda taksitler bütçede alış ayına toplanır; değiştirme yalnızca + // taksitsiz işlemlere uygulanır (tutarlar kategori toplamıyla tutarlı kalsın). + if (b.s.installmentMode === "purchase_month") match["installment.planId"] = { $in: [null] }; + const docs = await TransactionModel.find(match) + .select({ categoryId: 1, amountMinor: 1, kakeiboGroup: 1, splits: 1 }) + .lean< + { + categoryId: Types.ObjectId | null; + amountMinor: number; + kakeiboGroup: string | null; + splits: { categoryId: Types.ObjectId; amountMinor: number; kakeiboGroup?: string | null }[]; + }[] + >(); + const sums = new Map(); + const add = (categoryId: string, group: unknown, amount: number) => { + if (!isKakeiboGroup(group)) return; + const key = `${categoryId}:${group}`; + const cur = sums.get(key) ?? { categoryId, group, amountMinor: 0 }; + cur.amountMinor += amount; + sums.set(key, cur); + }; + for (const d of docs) { + if (d.splits?.length) + for (const s of d.splits) add(String(s.categoryId), s.kakeiboGroup, s.amountMinor); + else if (d.categoryId) add(String(d.categoryId), d.kakeiboGroup, d.amountMinor); + } + return [...sums.values()]; +} + +async function kakeiboMonth(b: Base, month: string): Promise { + const [amounts, over] = await Promise.all([actualByCategory(b, month), overrides(b, month)]); + const items = classifyAmounts(amounts, over, b.ctx.kakeibo); + const byClass = Object.fromEntries( + [...KAKEIBO_GROUPS, "BANK_COST", "UNGROUPED"].map((c) => [c, [] as KakeiboCategoryRow[]]), + ) as Record; + const merged = new Map(); + for (const it of items) { + const key = `${it.cls}:${it.categoryId}`; + const path = + it.categoryId === ORPHAN + ? tr.reports.uncategorized + : (b.ctx.categoryMap.get(it.categoryId)?.path ?? "—"); + const cur = merged.get(key) ?? { id: it.categoryId, path, amountMinor: 0, cls: it.cls }; + cur.amountMinor += it.amountMinor; + merged.set(key, cur); + } + for (const r of merged.values()) + byClass[r.cls].push({ id: r.id, path: r.path, amountMinor: r.amountMinor }); + for (const list of Object.values(byClass)) list.sort((a, c) => c.amountMinor - a.amountMinor); + return { summary: summarizeGroups(items), byClass }; +} + +export async function getKakeiboReport( + userId: string, + householdId: string, + month?: string, +): Promise { + const b = await base(userId, householdId); + if (!b) return null; + const m = month ?? b.currentMonth; + const previous = shiftMonth(m, -1); + const [now, prev] = await Promise.all([kakeiboMonth(b, m), kakeiboMonth(b, previous)]); + const change = Object.fromEntries( + [...KAKEIBO_GROUPS, "BANK_COST" as const].map((g) => { + const cur = g === "BANK_COST" ? now.summary.bankCostMinor : now.summary.groups[g]; + const old = g === "BANK_COST" ? prev.summary.bankCostMinor : prev.summary.groups[g]; + return [g, { minor: cur - old, pct: changePct(cur, old) }]; + }), + ) as KakeiboReport["change"]; + return { + ...monthInfo(b, m), + previous, + now, + prev, + change, + includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0, + }; +} diff --git a/src/features/reports/service.ts b/src/features/reports/service.ts index 0d118f2..0975682 100644 --- a/src/features/reports/service.ts +++ b/src/features/reports/service.ts @@ -26,7 +26,7 @@ import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from */ const DAY_MS = 86_400_000; -const ORPHAN = "__none__"; +export const ORPHAN = "__none__"; export type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string }; @@ -39,7 +39,7 @@ export async function base(userId: string, householdId: string): Promise(); diff --git a/src/features/transactions/service.ts b/src/features/transactions/service.ts index 7b1f423..100fb2a 100644 --- a/src/features/transactions/service.ts +++ b/src/features/transactions/service.ts @@ -24,6 +24,7 @@ import { allowedAttachmentKinds } from "@/shared/attachments"; import type { IconKey, Swatch } from "@/shared/appearance"; import { installmentSchedule } from "@/shared/card-periods"; import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates"; +import { classify, resolveKakeibo, type KakeiboGroup, type SpendingClass } from "@/shared/kakeibo"; import type { CategoryKind } from "@/shared/schemas/category"; import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction"; import { compareTr, foldTr } from "@/shared/search"; @@ -85,6 +86,8 @@ export async function loadContext( icon: 1, color: 1, ancestors: 1, + kakeiboGroup: 1, + bankCost: 1, }) .lean(), ContactModel.find({ householdId: m.householdId }) @@ -134,6 +137,15 @@ export async function loadContext( }, ]), ); + // Kakeibo: kategori başına etkin grup ve banka maliyeti (devralma dahil; Faz 16). + const kakeibo = resolveKakeibo( + categories.map((c) => ({ + id: String(c._id), + parentId: c.parentId ? String(c.parentId) : null, + kakeiboGroup: (c.kakeiboGroup ?? null) as KakeiboGroup | null, + bankCost: Boolean(c.bankCost), + })), + ); const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id); const contactMap = new Map( contacts.map((c) => [ @@ -170,6 +182,7 @@ export async function loadContext( assetMap, hiddenAssetIds, flatCategories: flat, + kakeibo, }; } @@ -291,7 +304,12 @@ export function buildDoc( let toAccountId: Types.ObjectId | null = null; let categoryId: Types.ObjectId | null = null; - let splits: { categoryId: Types.ObjectId; amountMinor: number; note: string }[] = []; + let splits: { + categoryId: Types.ObjectId; + amountMinor: number; + note: string; + kakeiboGroup: string | null; + }[] = []; if (input.type === "transfer") { toAccountId = checkAccount( @@ -316,7 +334,13 @@ export function buildDoc( return fail(t.validation.categoryInvalid, { [`splits.${i}.categoryId`]: t.validation.categoryInvalid, }); - splits.push({ categoryId: cid, amountMinor: s.amount, note: s.note }); + splits.push({ + categoryId: cid, + amountMinor: s.amount, + note: s.note, + // Kakeibo grubu yalnızca giderde anlamlı. + kakeiboGroup: input.type === "expense" ? (s.kakeiboGroup ?? null) : null, + }); } } else { categoryId = input.categoryId ? checkCategory(ctx, input.categoryId, kind, keep) : null; @@ -338,6 +362,8 @@ export function buildDoc( amountMinor: input.amount, categoryId, splits, + kakeiboGroup: + input.type === "expense" && splits.length === 0 ? (input.kakeiboGroup ?? null) : null, effectiveCategoryIds: effective, payeeName, description: input.description, @@ -650,7 +676,14 @@ export type TransactionDetail = TransactionListItem & { color: Swatch; amountMinor: number; note: string; + /** Bu kalem için değiştirilmiş Kakeibo grubu (null = kategoriden). */ + kakeiboGroup: KakeiboGroup | null; + /** Etkin sınıf (grup, banka maliyeti ya da grupsuz). */ + kakeiboClass: SpendingClass; }[]; + /** Bölünmemiş giderde değiştirilmiş Kakeibo grubu ve etkin sınıf (gider değilse null). */ + kakeiboGroup: KakeiboGroup | null; + kakeiboClass: SpendingClass | null; updatedBy: { id: string; name: string }; createdAt: string; updatedAt: string; @@ -830,8 +863,17 @@ export async function getTransaction( attachments, ...toItem(ctx, tx, names), categoryId: tx.categoryId ? String(tx.categoryId) : null, + kakeiboGroup: (tx.kakeiboGroup ?? null) as KakeiboGroup | null, + kakeiboClass: + tx.type === "expense" && tx.categoryId + ? classify( + ctx.kakeibo.get(String(tx.categoryId)), + (tx.kakeiboGroup ?? null) as KakeiboGroup | null, + ) + : null, splits: (tx.splits ?? []).map((s) => { const c = ctx.categoryMap.get(String(s.categoryId)); + const override = (s.kakeiboGroup ?? null) as KakeiboGroup | null; return { categoryId: String(s.categoryId), path: c?.path ?? "—", @@ -839,6 +881,8 @@ export async function getTransaction( color: c?.color ?? "slate", amountMinor: s.amountMinor, note: s.note ?? "", + kakeiboGroup: override, + kakeiboClass: classify(ctx.kakeibo.get(String(s.categoryId)), override), }; }), updatedBy: { id: String(tx.updatedBy), name: names.get(String(tx.updatedBy)) ?? "—" }, @@ -924,6 +968,8 @@ export type TransactionFormOptions = { accounts: { id: string; name: string; type: AccountType; icon: IconKey; color: Swatch }[]; categories: CategoryOption[]; categoryAppearance: Record; + /** Gider kategorisi başına etkin Kakeibo grubu (formda öneri) ve banka maliyeti. */ + categoryKakeibo: Record; }; export async function getTransactionFormOptions( @@ -950,7 +996,10 @@ export async function getTransactionFormOptions( const categoryAppearance = Object.fromEntries( [...ctx.categoryMap.values()].map((c) => [String(c.id), { icon: c.icon, color: c.color }]), ); - return { accounts, categories, categoryAppearance }; + const categoryKakeibo = Object.fromEntries( + [...ctx.kakeibo.entries()].map(([id, k]) => [id, { group: k.group, bankCost: k.bankCost }]), + ); + return { accounts, categories, categoryAppearance, categoryKakeibo }; } export type PayeeSuggestion = { diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 1bcf04e..61ebe19 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -1862,6 +1862,35 @@ export const tr = { identifierRequired: "Kullanıcı adı veya e-posta girin", passwordRequired: "Şifre girin", }, + kakeibo: { + groups: { + ZORUNLU: "Zorunlu", + ISTEK: "İstek", + KULTUR: "Kültür", + BEKLENMEYEN: "Beklenmeyen", + }, + groupHints: { + ZORUNLU: "İhtiyaç: market, faturalar, ulaşım, sağlık, sigorta, resmi ödemeler.", + ISTEK: + "Opsiyonel: dışarıda yemek, kahve-börek, giyim, kozmetik, hobi, online alışveriş, abonelik, tatil.", + KULTUR: "Kitap, kurs, eğitim, çocuk etkinlikleri, müze/konser, bağış.", + BEKLENMEYEN: "Plansız tamir/tadilat, büyük sağlık gideri, arıza, hediye/düğün.", + }, + bankCost: "Banka maliyeti", + bankCostHint: + "KMH faizi, KKDF/BSMV, kart ve hesap ücretleri. Harcama değildir; dört gruba ve harcama toplamına girmez.", + ungrouped: "Grupsuz", + ungroupedHint: "Kakeibo grubu seçilmemiş. Kategoriye grup atayın.", + field: "Kakeibo grubu", + inherit: "Üst kategoriden devral", + inheritWith: (group: string) => `Üst kategoriden: ${group}`, + fromCategory: (group: string) => `Kategoriden: ${group}`, + fromCategoryNone: "Kategoriden (grupsuz)", + overrideHint: "Kategoriyi ürün değil amaç belirler; bu kayıt için grubu değiştirebilirsiniz.", + inherited: "devralınan", + ungroupedCount: (n: number) => + `${n} gider kategorisinin Kakeibo grubu yok; kategoriyi düzenleyip grup seçin.`, + }, reports: { title: "Raporlar", description: @@ -1904,6 +1933,10 @@ export const tr = { title: "Cari ve aynî borçlar", description: "Kim kime ne kadar borçlu; açık altın/döviz borç ve alacakları.", }, + kakeibo: { + title: "Kakeibo grupları", + description: "Zorunlu, İstek, Kültür, Beklenmeyen; banka maliyeti ve grupsuz harcamalar.", + }, assets: { title: "Birikim kâr/zarar", description: "Varlık başına maliyet, değer ve kâr/zarar; dönem özeti.", @@ -1992,6 +2025,19 @@ export const tr = { periods: { 3: "Son 3 ay", 6: "Son 6 ay", 12: "Son 12 ay" } as Record, periodLabel: "Dönem", rangeText: (from: string, to: string) => `${from} – ${to}`, + kakeiboReport: { + title: "Kakeibo grupları", + description: (month: string) => + `${month} bütçe ayı; harcamanın Zorunlu, İstek, Kültür ve Beklenmeyen gruplarına dağılımı.`, + spending: "Kakeibo harcaması", + chartLabel: "Harcamanın dört Kakeibo grubuna dağılımı, yığılmış çubuk", + bankCostNote: "Harcamaya ve gruplara dahil değil.", + noSpending: "Bu ay harcama yok.", + ungroupedHelp: "Bu kategorilerin Kakeibo grubu seçilmemiş.", + goCategories: "Kategorilere grup ata", + csvHeader: ["Grup", "Kategori", "Tutar"], + csvFile: (month: string) => `butcem-kakeibo-${month}.csv`, + }, contacts: { title: "Cari ve aynî borçlar", description: diff --git a/src/models/category.ts b/src/models/category.ts index e36a91d..7f06daa 100644 --- a/src/models/category.ts +++ b/src/models/category.ts @@ -1,5 +1,6 @@ import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; import { ICON_KEYS, SWATCHES } from "@/shared/appearance"; +import { KAKEIBO_GROUPS } from "@/shared/kakeibo"; 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). */ @@ -16,6 +17,13 @@ const categorySchema = new Schema( icon: { type: String, enum: ICON_KEYS, default: "tag" }, color: { type: String, enum: SWATCHES, default: "slate" }, order: { type: Number, default: 0 }, + /** + * Kakeibo grubu (yalnızca gider; Faz 16). null ise üst kategoriden devralınır; zincirde yoksa + * grupsuz. + */ + kakeiboGroup: { type: String, enum: [...KAKEIBO_GROUPS, null], default: null }, + /** Banka maliyeti (KMH faizi, KKDF/BSMV, ücret): Kakeibo harcamasına girmez; alt ağaç devralır. */ + bankCost: { type: Boolean, default: false }, archivedAt: { type: Date, default: null }, /** Varsayılan setten geldiyse sabit anahtar (tekrar yüklemede kopya olmaz). */ defaultKey: { type: String, default: null }, diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 18cdd8d..e715b68 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -1,5 +1,6 @@ import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; import { CONTACT_ENTRY_KINDS } from "@/shared/contacts"; +import { KAKEIBO_GROUPS } from "@/shared/kakeibo"; import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions"; /** @@ -11,6 +12,8 @@ const splitSchema = new Schema( categoryId: { type: Schema.Types.ObjectId, required: true }, amountMinor: { type: Number, required: true, min: 1 }, note: { type: String, default: "", maxlength: 120 }, + /** Kakeibo grubu bu kalem için değiştirildiyse; null = kategoriden (Faz 16). */ + kakeiboGroup: { type: String, enum: [...KAKEIBO_GROUPS, null], default: null }, }, { _id: true }, ); @@ -29,6 +32,8 @@ const transactionSchema = new Schema( /** Bölünmemiş gelir/giderde kategori; bölünmüşte null ve kalemler `splits`te. */ categoryId: { type: Schema.Types.ObjectId, default: null }, splits: { type: [splitSchema], default: [] }, + /** Bölünmemiş giderde Kakeibo grubu değiştirildiyse; null = kategoriden (Faz 16). */ + kakeiboGroup: { type: String, enum: [...KAKEIBO_GROUPS, null], default: null }, /** Kategori filtresi/raporlar için: categoryId veya kalem kategorileri (tekilleştirilmiş). */ effectiveCategoryIds: { type: [Schema.Types.ObjectId], default: [] }, payeeId: { type: Schema.Types.ObjectId, default: null }, diff --git a/src/shared/kakeibo.test.ts b/src/shared/kakeibo.test.ts new file mode 100644 index 0000000..41fc1bd --- /dev/null +++ b/src/shared/kakeibo.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + classify, + classifyAmounts, + resolveKakeibo, + summarizeGroups, + type KakeiboCategory, +} from "./kakeibo"; + +const cat = ( + id: string, + parentId: string | null, + kakeiboGroup: KakeiboCategory["kakeiboGroup"] = null, + bankCost = false, +): KakeiboCategory => ({ id, parentId, kakeiboGroup, bankCost }); + +describe("resolveKakeibo", () => { + const map = resolveKakeibo([ + cat("market", null, "ZORUNLU"), + cat("gida", "market"), + cat("bakim", "market", "ISTEK"), + cat("banka", null, null, true), + cat("faiz", "banka"), + cat("diger", null), + // Döngü: kendine üst (bozuk veri) sonsuz döngüye girmemeli. + cat("dongu", "dongu"), + ]); + it("alt kategori grubu üstten devralır; kendi değeri önceliklidir", () => { + expect(map.get("gida")).toEqual({ group: "ZORUNLU", inherited: true, bankCost: false }); + expect(map.get("bakim")).toEqual({ group: "ISTEK", inherited: false, bankCost: false }); + expect(map.get("market")!.inherited).toBe(false); + }); + it("banka maliyeti alt kategorilere geçer; grupsuz ve döngülü kategori güvenli", () => { + expect(map.get("faiz")!.bankCost).toBe(true); + expect(map.get("diger")).toEqual({ group: null, inherited: false, bankCost: false }); + expect(map.get("dongu")!.group).toBeNull(); + }); +}); + +describe("classify", () => { + const map = resolveKakeibo([cat("market", null, "ZORUNLU"), cat("faiz", null, null, true)]); + it("işlem bazında grup kategoriyi ezer; banka maliyeti her zaman banka maliyeti", () => { + expect(classify(map.get("market"), null)).toBe("ZORUNLU"); + expect(classify(map.get("market"), "ISTEK")).toBe("ISTEK"); + expect(classify(map.get("faiz"), "ISTEK")).toBe("BANK_COST"); + expect(classify(undefined, null)).toBe("UNGROUPED"); + expect(classify(undefined, "KULTUR")).toBe("KULTUR"); + }); +}); + +describe("summarizeGroups", () => { + it("banka maliyeti harcamaya ve paylara girmez; grupsuz harcamaya girer", () => { + const s = summarizeGroups([ + { cls: "ZORUNLU", amountMinor: 4_600 }, + { cls: "ISTEK", amountMinor: 3_900 }, + { cls: "KULTUR", amountMinor: 150 }, + { cls: "BEKLENMEYEN", amountMinor: 1_250 }, + { cls: "UNGROUPED", amountMinor: 100 }, + { cls: "BANK_COST", amountMinor: 999 }, + ]); + expect(s.spendingMinor).toBe(10_000); + expect(s.bankCostMinor).toBe(999); + expect(s.shares).toEqual({ ZORUNLU: 46, ISTEK: 39, KULTUR: 1.5, BEKLENMEYEN: 12.5 }); + expect(s.ungroupedShare).toBe(1); + }); + it("harcama yoksa paylar 0 (sıfıra bölme yok)", () => { + const s = summarizeGroups([{ cls: "BANK_COST", amountMinor: 50 }]); + expect(s.spendingMinor).toBe(0); + expect(s.shares.ZORUNLU).toBe(0); + expect(s.ungroupedShare).toBe(0); + }); +}); + +describe("classifyAmounts", () => { + const resolved = resolveKakeibo([ + cat("market", null, "ZORUNLU"), + cat("faiz", null, null, true), + cat("diger", null), + ]); + it("değiştirilen tutar seçilen gruba taşınır, kategori toplamı korunur", () => { + const out = classifyAmounts( + new Map([ + ["market", 10_000], + ["faiz", 500], + ["diger", 300], + ]), + [ + { categoryId: "market", group: "ISTEK", amountMinor: 2_500 }, + // Banka maliyetinde grup değiştirme etkisiz. + { categoryId: "faiz", group: "ISTEK", amountMinor: 500 }, + // Kategori toplamını aşan değiştirme kırpılır. + { categoryId: "diger", group: "KULTUR", amountMinor: 900 }, + ], + resolved, + ); + expect(out).toEqual([ + { categoryId: "market", cls: "ISTEK", amountMinor: 2_500 }, + { categoryId: "market", cls: "ZORUNLU", amountMinor: 7_500 }, + { categoryId: "faiz", cls: "BANK_COST", amountMinor: 500 }, + { categoryId: "diger", cls: "KULTUR", amountMinor: 300 }, + ]); + const s = summarizeGroups(out); + expect(s.spendingMinor + s.bankCostMinor).toBe(10_800); + }); +}); diff --git a/src/shared/kakeibo.ts b/src/shared/kakeibo.ts new file mode 100644 index 0000000..3123873 --- /dev/null +++ b/src/shared/kakeibo.ts @@ -0,0 +1,175 @@ +/** + * Kakeibo (家計簿) dört harcama grubu. Saf fonksiyonlar. + * + * Her gider tam olarak bir gruba girer: ZORUNLU (ihtiyaç), ISTEK (opsiyonel), KULTUR, + * BEKLENMEYEN. Grup kategoriden gelir (kategori → yoksa üst kategoriden devralınır); kayıt + * sırasında işlem ya da bölünmüş işlem kalemi bazında değiştirilebilir ("kategoriyi ürün değil + * amaç belirler"). + * + * Banka maliyeti (KMH faizi, KKDF/BSMV, kart/hesap ücretleri) harcama değildir: dört gruba ve + * Kakeibo harcama toplamına girmez, ayrı gösterilir. Kategori ya da üst kategorisi "banka + * maliyeti" işaretliyse kalem grup değiştirilse bile banka maliyetidir. + * + * Grubu belirlenemeyen gider "grupsuz"dur: harcama toplamına girer, kullanıcıdan grup seçmesi + * istenir. + */ + +export const KAKEIBO_GROUPS = ["ZORUNLU", "ISTEK", "KULTUR", "BEKLENMEYEN"] as const; +export type KakeiboGroup = (typeof KAKEIBO_GROUPS)[number]; + +export const isKakeiboGroup = (x: unknown): x is KakeiboGroup => + KAKEIBO_GROUPS.includes(x as KakeiboGroup); + +/** Harcama sınıfı: dört grup, banka maliyeti ya da grupsuz. */ +export type SpendingClass = KakeiboGroup | "BANK_COST" | "UNGROUPED"; + +export type KakeiboCategory = { + id: string; + parentId: string | null; + kakeiboGroup: KakeiboGroup | null; + bankCost: boolean; +}; + +export type ResolvedKakeibo = { + /** Etkin grup (kendisi ya da en yakın üst kategori); yoksa null. */ + group: KakeiboGroup | null; + /** Grup üst kategoriden mi geliyor? */ + inherited: boolean; + /** Kendisi ya da bir üst kategorisi banka maliyeti. */ + bankCost: boolean; +}; + +/** Kategori başına etkin grup ve banka maliyeti (döngüye karşı korumalı). */ +export function resolveKakeibo( + categories: readonly KakeiboCategory[], +): Map { + const byId = new Map(categories.map((c) => [c.id, c])); + const out = new Map(); + for (const c of categories) { + let group: KakeiboGroup | null = null; + let inherited = false; + let bankCost = false; + const seen = new Set(); + let cur: KakeiboCategory | undefined = c; + while (cur && !seen.has(cur.id)) { + seen.add(cur.id); + if (cur.bankCost) bankCost = true; + if (!group && cur.kakeiboGroup) { + group = cur.kakeiboGroup; + inherited = cur !== c; + } + cur = cur.parentId ? byId.get(cur.parentId) : undefined; + } + out.set(c.id, { group, inherited, bankCost }); + } + return out; +} + +/** Bir gider kaleminin sınıfı: banka maliyeti > işlem bazında grup > kategorinin grubu. */ +export function classify( + category: ResolvedKakeibo | undefined, + override: KakeiboGroup | null, +): SpendingClass { + if (category?.bankCost) return "BANK_COST"; + return override ?? category?.group ?? "UNGROUPED"; +} + +export type GroupSummary = { + /** Grup başına tutar. */ + groups: Record; + ungroupedMinor: number; + bankCostMinor: number; + /** Kakeibo harcaması: dört grup + grupsuz (banka maliyeti hariç). */ + spendingMinor: number; + /** Grubun harcamadaki payı (bir ondalık yüzde; harcama 0 ise 0). */ + shares: Record; + ungroupedShare: number; +}; + +const pct = (part: number, total: number) => + total > 0 ? Math.round((part * 1000) / total) / 10 : 0; + +export function summarizeGroups( + items: readonly { cls: SpendingClass; amountMinor: number }[], +): GroupSummary { + const groups = Object.fromEntries(KAKEIBO_GROUPS.map((g) => [g, 0])) as Record< + KakeiboGroup, + number + >; + let ungrouped = 0; + let bank = 0; + for (const it of items) { + if (it.cls === "BANK_COST") bank += it.amountMinor; + else if (it.cls === "UNGROUPED") ungrouped += it.amountMinor; + else groups[it.cls] += it.amountMinor; + } + const spending = KAKEIBO_GROUPS.reduce((a, g) => a + groups[g], 0) + ungrouped; + return { + groups, + ungroupedMinor: ungrouped, + bankCostMinor: bank, + spendingMinor: spending, + shares: Object.fromEntries(KAKEIBO_GROUPS.map((g) => [g, pct(groups[g], spending)])) as Record< + KakeiboGroup, + number + >, + ungroupedShare: pct(ungrouped, spending), + }; +} + +/** + * Varsayılan kategori setinin Kakeibo eşlemesi (anahtar → grup). Alt kategori kendi değeri + * yoksa üstünden devralır. Listede olmayan varsayılanlar (ör. "Diğer") grupsuz kalır. + */ +export const DEFAULT_KAKEIBO_GROUPS: Record = { + market: "ZORUNLU", + "market.kisisel-bakim": "ISTEK", + faturalar: "ZORUNLU", + kira: "ZORUNLU", + aidat: "ZORUNLU", + ulasim: "ZORUNLU", + saglik: "ZORUNLU", + egitim: "KULTUR", + giyim: "ISTEK", + eglence: "ISTEK", + abonelikler: "ISTEK", + ev: "BEKLENMEYEN", + cocuk: "KULTUR", + "evcil-hayvan": "ZORUNLU", + hediye: "BEKLENMEYEN", + vergi: "ZORUNLU", +}; + +/** Banka maliyeti işaretli varsayılan kategoriler (alt kategoriler devralır). */ +export const DEFAULT_BANK_COST_KEYS = ["banka"] as const; + +export type ClassifiedAmount = { categoryId: string; cls: SpendingClass; amountMinor: number }; + +/** + * Kategori tutarlarını sınıflara dağıtır. `overrides`: işlem/kalem bazında grubu değiştirilmiş + * tutarlar (kategori × grup); bunlar kategorinin varsayılan payından düşülüp seçilen gruba + * yazılır. Kategori toplamı her zaman korunur (taşma olursa varsayılan pay 0'a çekilir). + */ +export function classifyAmounts( + categoryAmounts: ReadonlyMap, + overrides: readonly { categoryId: string; group: KakeiboGroup; amountMinor: number }[], + resolved: ReadonlyMap, +): ClassifiedAmount[] { + const out: ClassifiedAmount[] = []; + const byCategory = new Map(); + for (const o of overrides) + byCategory.set(o.categoryId, [...(byCategory.get(o.categoryId) ?? []), o]); + for (const [categoryId, total] of categoryAmounts) { + if (!total) continue; + const info = resolved.get(categoryId); + let rest = total; + for (const o of byCategory.get(categoryId) ?? []) { + const part = Math.min(o.amountMinor, Math.max(0, rest)); + if (!part) continue; + rest -= part; + out.push({ categoryId, cls: classify(info, o.group), amountMinor: part }); + } + if (rest) out.push({ categoryId, cls: classify(info, null), amountMinor: rest }); + } + return out; +} diff --git a/src/shared/schemas/category.ts b/src/shared/schemas/category.ts index b300718..a0a8485 100644 --- a/src/shared/schemas/category.ts +++ b/src/shared/schemas/category.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { tr } from "../../i18n/tr"; import { ICON_KEYS, SWATCHES } from "../appearance"; +import { KAKEIBO_GROUPS } from "../kakeibo"; export const CATEGORY_KINDS = ["expense", "income"] as const; export type CategoryKind = (typeof CATEGORY_KINDS)[number]; @@ -14,6 +15,13 @@ export const categoryFormSchema = z.object({ .transform((s) => (s === "" ? null : s)), icon: z.enum(ICON_KEYS), color: z.enum(SWATCHES), + /** Kakeibo grubu ("" = üst kategoriden devral); yalnızca gider. */ + kakeiboGroup: z + .union([z.enum(KAKEIBO_GROUPS), z.literal("")]) + .default("") + .transform((x) => (x === "" ? null : x)), + /** Banka maliyeti (KMH faizi, ücret): Kakeibo harcamasına girmez. */ + bankCost: z.boolean().default(false), }); export type CategoryFormInput = z.input; diff --git a/src/shared/schemas/transaction.ts b/src/shared/schemas/transaction.ts index 0fb9c48..e20fb42 100644 --- a/src/shared/schemas/transaction.ts +++ b/src/shared/schemas/transaction.ts @@ -3,6 +3,7 @@ import { tr } from "../../i18n/tr"; import { parseMoney } from "../money"; import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments"; import { MAX_INSTALLMENTS } from "../card-periods"; +import { KAKEIBO_GROUPS } from "../kakeibo"; import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions"; const v = tr.validation; @@ -28,10 +29,17 @@ const positiveMoney = z return minor; }); +/** Kakeibo grubu bu kayıt için değiştirildiyse; "" / yok = kategoriden (Faz 16). */ +const kakeiboOverride = z + .union([z.enum(KAKEIBO_GROUPS), z.literal(""), z.null()]) + .transform((x) => (x ? x : null)) + .optional(); + const splitSchema = z.object({ categoryId: objectId.refine(Boolean, t.selectCategory), amount: positiveMoney, note: z.string().trim().max(120).default(""), + kakeiboGroup: kakeiboOverride, }); /** @@ -48,6 +56,7 @@ export const transactionFormSchema = z toAccountId: optionalId, categoryId: optionalId, splits: z.array(splitSchema).max(MAX_SPLITS).default([]), + kakeiboGroup: kakeiboOverride, payee: z.string().trim().max(80).default(""), description: z.string().trim().max(500).default(""), tags: z