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
+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