feat(kakeibo): dört harcama grubu, banka maliyeti ve geri alınabilir migration

- 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 <ad> 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 04:51:35 +03:00
parent 18c892bdcd
commit afc6da1606
17 changed files with 831 additions and 13 deletions
@@ -31,6 +31,8 @@ const form = (over: Partial<Parameters<typeof createCategory>[2]> = {}) => ({
parentId: null,
icon: "tag" as const,
color: "blue" as const,
kakeiboGroup: null,
bankCost: false,
...over,
});
+60 -5
View File
@@ -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<CategoryView, "kakeibo"> & { 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<string, unknown> = { householdId: m.householdId };
if (opts.kind) filter.kind = opts.kind;
if (!opts.includeArchived) filter.archivedAt = null;
const rows = await CategoryModel.find(filter).lean<Lean[]>();
return rows.map(toView);
const all = await CategoryModel.find(filter).lean<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) {
+141
View File
@@ -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<ReturnType<typeof setupSeededDb>>;
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<string, unknown>) =>
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);
});
});
+138
View File
@@ -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<SpendingClass, KakeiboCategoryRow[]>;
};
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<KakeiboGroup | "BANK_COST", { minor: number; pct: number | null }>;
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<string, unknown> = {
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<string, { categoryId: string; group: KakeiboGroup; amountMinor: number }>();
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<KakeiboMonth> {
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<SpendingClass, KakeiboCategoryRow[]>;
const merged = new Map<string, KakeiboCategoryRow & { cls: SpendingClass }>();
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<KakeiboReport | null> {
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,
};
}
+2 -2
View File
@@ -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<Base |
}
/** Kategori bazında gerçekleşen gider (kendi tutarı); kategorisiz/bilinmeyen tek satırda. */
async function actualByCategory(b: Base, month: string) {
export async function actualByCategory(b: Base, month: string) {
const { from, to } = budgetPeriod(month, b.s.startDay);
const amounts = await expenseAmounts(b.ctx, b.s, from, to);
const out = new Map<string, number>();
+52 -3
View File
@@ -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<string, { icon: IconKey; color: Swatch }>;
/** Gider kategorisi başına etkin Kakeibo grubu (formda öneri) ve banka maliyeti. */
categoryKakeibo: Record<string, { group: KakeiboGroup | null; bankCost: boolean }>;
};
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 = {
+46
View File
@@ -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<number, string>,
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:
+8
View File
@@ -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 },
+5
View File
@@ -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 },
+105
View File
@@ -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);
});
});
+175
View File
@@ -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<string, ResolvedKakeibo> {
const byId = new Map(categories.map((c) => [c.id, c]));
const out = new Map<string, ResolvedKakeibo>();
for (const c of categories) {
let group: KakeiboGroup | null = null;
let inherited = false;
let bankCost = false;
const seen = new Set<string>();
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<KakeiboGroup, number>;
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<KakeiboGroup, number>;
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<string, KakeiboGroup> = {
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<string, number>,
overrides: readonly { categoryId: string; group: KakeiboGroup; amountMinor: number }[],
resolved: ReadonlyMap<string, ResolvedKakeibo>,
): ClassifiedAmount[] {
const out: ClassifiedAmount[] = [];
const byCategory = new Map<string, { group: KakeiboGroup; amountMinor: number }[]>();
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;
}
+8
View File
@@ -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<typeof categoryFormSchema>;
+9
View File
@@ -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