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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user