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:
+10
-2
@@ -4,9 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
import { getMongoClient } from "@/lib/db/mongo";
|
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 <ad> → 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) => {
|
.catch((err: unknown) => {
|
||||||
console.error(err instanceof Error ? err.message : err);
|
console.error(err instanceof Error ? err.message : err);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
@@ -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": "" } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import { migration as m012 } from "./012-stock-counts";
|
|||||||
import { migration as m013 } from "./013-stores-products";
|
import { migration as m013 } from "./013-stores-products";
|
||||||
import { migration as m014 } from "./014-product-prices";
|
import { migration as m014 } from "./014-product-prices";
|
||||||
import { migration as m015 } from "./015-shopping-lists";
|
import { migration as m015 } from "./015-shopping-lists";
|
||||||
|
import { migration as m016 } from "./016-kakeibo-groups";
|
||||||
import type { Migration } from "./types";
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||||
@@ -34,6 +35,7 @@ export const MIGRATIONS: Migration[] = [
|
|||||||
m013,
|
m013,
|
||||||
m014,
|
m014,
|
||||||
m015,
|
m015,
|
||||||
|
m016,
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
@@ -63,3 +65,23 @@ export async function runMigrations(log: (line: string) => void = console.log):
|
|||||||
if (!ran.length) log("• Bekleyen migration yok.");
|
if (!ran.length) log("• Bekleyen migration yok.");
|
||||||
return ran;
|
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<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { Db } from "mongodb";
|
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 <ad>).
|
||||||
|
*/
|
||||||
export type Migration = {
|
export type Migration = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
up: (db: Db) => Promise<void>;
|
up: (db: Db) => Promise<void>;
|
||||||
|
down?: (db: Db) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const form = (over: Partial<Parameters<typeof createCategory>[2]> = {}) => ({
|
|||||||
parentId: null,
|
parentId: null,
|
||||||
icon: "tag" as const,
|
icon: "tag" as const,
|
||||||
color: "blue" as const,
|
color: "blue" as const,
|
||||||
|
kakeiboGroup: null,
|
||||||
|
bankCost: false,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import { toObjectId } from "@/lib/object-id";
|
|||||||
import { CategoryModel } from "@/models/category";
|
import { CategoryModel } from "@/models/category";
|
||||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
import type { CategoryForm, CategoryKind } from "@/shared/schemas/category";
|
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 { foldTr } from "@/shared/search";
|
||||||
import { DEFAULT_CATEGORIES } from "./defaults";
|
import { DEFAULT_CATEGORIES } from "./defaults";
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +33,16 @@ import {
|
|||||||
|
|
||||||
const t = tr.categories;
|
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 = {
|
type Lean = {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
@@ -39,9 +55,26 @@ type Lean = {
|
|||||||
color: string;
|
color: string;
|
||||||
defaultKey: string | null;
|
defaultKey: string | null;
|
||||||
ancestors: Types.ObjectId[];
|
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),
|
id: String(c._id),
|
||||||
kind: c.kind,
|
kind: c.kind,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
@@ -51,8 +84,16 @@ const toView = (c: Lean): CategoryView => ({
|
|||||||
icon: c.icon as IconKey,
|
icon: c.icon as IconKey,
|
||||||
color: c.color as Swatch,
|
color: c.color as Swatch,
|
||||||
isDefault: Boolean(c.defaultKey),
|
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];
|
const violationMessage = (v: MoveViolation) => t.violations[v];
|
||||||
|
|
||||||
export async function listCategories(
|
export async function listCategories(
|
||||||
@@ -64,9 +105,9 @@ export async function listCategories(
|
|||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const filter: Record<string, unknown> = { householdId: m.householdId };
|
const filter: Record<string, unknown> = { householdId: m.householdId };
|
||||||
if (opts.kind) filter.kind = opts.kind;
|
if (opts.kind) filter.kind = opts.kind;
|
||||||
if (!opts.includeArchived) filter.archivedAt = null;
|
const all = await CategoryModel.find(filter).lean<Lean[]>();
|
||||||
const rows = await CategoryModel.find(filter).lean<Lean[]>();
|
const rows = opts.includeArchived ? all : all.filter((c) => !c.archivedAt);
|
||||||
return rows.map(toView);
|
return toViews(rows, all);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) {
|
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ı
|
* 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.
|
* 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,
|
color: def.color,
|
||||||
order,
|
order,
|
||||||
defaultKey: def.key,
|
defaultKey: def.key,
|
||||||
|
...(kind === "expense" ? defaultKakeibo(def.key) : {}),
|
||||||
createdBy: actor,
|
createdBy: actor,
|
||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
},
|
},
|
||||||
@@ -164,6 +217,7 @@ export async function createCategory(
|
|||||||
ancestors: parent ? [...parent.ancestors, parent._id] : [],
|
ancestors: parent ? [...parent.ancestors, parent._id] : [],
|
||||||
icon: input.icon,
|
icon: input.icon,
|
||||||
color: input.color,
|
color: input.color,
|
||||||
|
...kakeiboFields(input),
|
||||||
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
|
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
|
||||||
createdBy: actor,
|
createdBy: actor,
|
||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
@@ -209,6 +263,7 @@ export async function updateCategory(
|
|||||||
nameKey: foldTr(input.name),
|
nameKey: foldTr(input.name),
|
||||||
icon: input.icon,
|
icon: input.icon,
|
||||||
color: input.color,
|
color: input.color,
|
||||||
|
...kakeiboFields(input),
|
||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
};
|
};
|
||||||
if (moving) {
|
if (moving) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
const DAY_MS = 86_400_000;
|
||||||
const ORPHAN = "__none__";
|
export const ORPHAN = "__none__";
|
||||||
|
|
||||||
export type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
|
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. */
|
/** 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 { from, to } = budgetPeriod(month, b.s.startDay);
|
||||||
const amounts = await expenseAmounts(b.ctx, b.s, from, to);
|
const amounts = await expenseAmounts(b.ctx, b.s, from, to);
|
||||||
const out = new Map<string, number>();
|
const out = new Map<string, number>();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { allowedAttachmentKinds } from "@/shared/attachments";
|
|||||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
import { installmentSchedule } from "@/shared/card-periods";
|
import { installmentSchedule } from "@/shared/card-periods";
|
||||||
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
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 { CategoryKind } from "@/shared/schemas/category";
|
||||||
import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction";
|
import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction";
|
||||||
import { compareTr, foldTr } from "@/shared/search";
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
@@ -85,6 +86,8 @@ export async function loadContext(
|
|||||||
icon: 1,
|
icon: 1,
|
||||||
color: 1,
|
color: 1,
|
||||||
ancestors: 1,
|
ancestors: 1,
|
||||||
|
kakeiboGroup: 1,
|
||||||
|
bankCost: 1,
|
||||||
})
|
})
|
||||||
.lean(),
|
.lean(),
|
||||||
ContactModel.find({ householdId: m.householdId })
|
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 hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id);
|
||||||
const contactMap = new Map(
|
const contactMap = new Map(
|
||||||
contacts.map((c) => [
|
contacts.map((c) => [
|
||||||
@@ -170,6 +182,7 @@ export async function loadContext(
|
|||||||
assetMap,
|
assetMap,
|
||||||
hiddenAssetIds,
|
hiddenAssetIds,
|
||||||
flatCategories: flat,
|
flatCategories: flat,
|
||||||
|
kakeibo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +304,12 @@ export function buildDoc(
|
|||||||
|
|
||||||
let toAccountId: Types.ObjectId | null = null;
|
let toAccountId: Types.ObjectId | null = null;
|
||||||
let categoryId: 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") {
|
if (input.type === "transfer") {
|
||||||
toAccountId = checkAccount(
|
toAccountId = checkAccount(
|
||||||
@@ -316,7 +334,13 @@ export function buildDoc(
|
|||||||
return fail(t.validation.categoryInvalid, {
|
return fail(t.validation.categoryInvalid, {
|
||||||
[`splits.${i}.categoryId`]: 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 {
|
} else {
|
||||||
categoryId = input.categoryId ? checkCategory(ctx, input.categoryId, kind, keep) : null;
|
categoryId = input.categoryId ? checkCategory(ctx, input.categoryId, kind, keep) : null;
|
||||||
@@ -338,6 +362,8 @@ export function buildDoc(
|
|||||||
amountMinor: input.amount,
|
amountMinor: input.amount,
|
||||||
categoryId,
|
categoryId,
|
||||||
splits,
|
splits,
|
||||||
|
kakeiboGroup:
|
||||||
|
input.type === "expense" && splits.length === 0 ? (input.kakeiboGroup ?? null) : null,
|
||||||
effectiveCategoryIds: effective,
|
effectiveCategoryIds: effective,
|
||||||
payeeName,
|
payeeName,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
@@ -650,7 +676,14 @@ export type TransactionDetail = TransactionListItem & {
|
|||||||
color: Swatch;
|
color: Swatch;
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
note: string;
|
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 };
|
updatedBy: { id: string; name: string };
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -830,8 +863,17 @@ export async function getTransaction(
|
|||||||
attachments,
|
attachments,
|
||||||
...toItem(ctx, tx, names),
|
...toItem(ctx, tx, names),
|
||||||
categoryId: tx.categoryId ? String(tx.categoryId) : null,
|
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) => {
|
splits: (tx.splits ?? []).map((s) => {
|
||||||
const c = ctx.categoryMap.get(String(s.categoryId));
|
const c = ctx.categoryMap.get(String(s.categoryId));
|
||||||
|
const override = (s.kakeiboGroup ?? null) as KakeiboGroup | null;
|
||||||
return {
|
return {
|
||||||
categoryId: String(s.categoryId),
|
categoryId: String(s.categoryId),
|
||||||
path: c?.path ?? "—",
|
path: c?.path ?? "—",
|
||||||
@@ -839,6 +881,8 @@ export async function getTransaction(
|
|||||||
color: c?.color ?? "slate",
|
color: c?.color ?? "slate",
|
||||||
amountMinor: s.amountMinor,
|
amountMinor: s.amountMinor,
|
||||||
note: s.note ?? "",
|
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)) ?? "—" },
|
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 }[];
|
accounts: { id: string; name: string; type: AccountType; icon: IconKey; color: Swatch }[];
|
||||||
categories: CategoryOption[];
|
categories: CategoryOption[];
|
||||||
categoryAppearance: Record<string, { icon: IconKey; color: Swatch }>;
|
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(
|
export async function getTransactionFormOptions(
|
||||||
@@ -950,7 +996,10 @@ export async function getTransactionFormOptions(
|
|||||||
const categoryAppearance = Object.fromEntries(
|
const categoryAppearance = Object.fromEntries(
|
||||||
[...ctx.categoryMap.values()].map((c) => [String(c.id), { icon: c.icon, color: c.color }]),
|
[...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 = {
|
export type PayeeSuggestion = {
|
||||||
|
|||||||
@@ -1862,6 +1862,35 @@ export const tr = {
|
|||||||
identifierRequired: "Kullanıcı adı veya e-posta girin",
|
identifierRequired: "Kullanıcı adı veya e-posta girin",
|
||||||
passwordRequired: "Şifre 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: {
|
reports: {
|
||||||
title: "Raporlar",
|
title: "Raporlar",
|
||||||
description:
|
description:
|
||||||
@@ -1904,6 +1933,10 @@ export const tr = {
|
|||||||
title: "Cari ve aynî borçlar",
|
title: "Cari ve aynî borçlar",
|
||||||
description: "Kim kime ne kadar borçlu; açık altın/döviz borç ve alacakları.",
|
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: {
|
assets: {
|
||||||
title: "Birikim kâr/zarar",
|
title: "Birikim kâr/zarar",
|
||||||
description: "Varlık başına maliyet, değer ve kâr/zarar; dönem özeti.",
|
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>,
|
periods: { 3: "Son 3 ay", 6: "Son 6 ay", 12: "Son 12 ay" } as Record<number, string>,
|
||||||
periodLabel: "Dönem",
|
periodLabel: "Dönem",
|
||||||
rangeText: (from: string, to: string) => `${from} – ${to}`,
|
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: {
|
contacts: {
|
||||||
title: "Cari ve aynî borçlar",
|
title: "Cari ve aynî borçlar",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
import { ICON_KEYS, SWATCHES } from "@/shared/appearance";
|
import { ICON_KEYS, SWATCHES } from "@/shared/appearance";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
import { CATEGORY_KINDS } from "@/shared/schemas/category";
|
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). */
|
/** 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" },
|
icon: { type: String, enum: ICON_KEYS, default: "tag" },
|
||||||
color: { type: String, enum: SWATCHES, default: "slate" },
|
color: { type: String, enum: SWATCHES, default: "slate" },
|
||||||
order: { type: Number, default: 0 },
|
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 },
|
archivedAt: { type: Date, default: null },
|
||||||
/** Varsayılan setten geldiyse sabit anahtar (tekrar yüklemede kopya olmaz). */
|
/** Varsayılan setten geldiyse sabit anahtar (tekrar yüklemede kopya olmaz). */
|
||||||
defaultKey: { type: String, default: null },
|
defaultKey: { type: String, default: null },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
import { CONTACT_ENTRY_KINDS } from "@/shared/contacts";
|
import { CONTACT_ENTRY_KINDS } from "@/shared/contacts";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
|
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,6 +12,8 @@ const splitSchema = new Schema(
|
|||||||
categoryId: { type: Schema.Types.ObjectId, required: true },
|
categoryId: { type: Schema.Types.ObjectId, required: true },
|
||||||
amountMinor: { type: Number, required: true, min: 1 },
|
amountMinor: { type: Number, required: true, min: 1 },
|
||||||
note: { type: String, default: "", maxlength: 120 },
|
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 },
|
{ _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. */
|
/** Bölünmemiş gelir/giderde kategori; bölünmüşte null ve kalemler `splits`te. */
|
||||||
categoryId: { type: Schema.Types.ObjectId, default: null },
|
categoryId: { type: Schema.Types.ObjectId, default: null },
|
||||||
splits: { type: [splitSchema], default: [] },
|
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ş). */
|
/** Kategori filtresi/raporlar için: categoryId veya kalem kategorileri (tekilleştirilmiş). */
|
||||||
effectiveCategoryIds: { type: [Schema.Types.ObjectId], default: [] },
|
effectiveCategoryIds: { type: [Schema.Types.ObjectId], default: [] },
|
||||||
payeeId: { type: Schema.Types.ObjectId, default: null },
|
payeeId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
|||||||
@@ -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 { z } from "zod";
|
||||||
import { tr } from "../../i18n/tr";
|
import { tr } from "../../i18n/tr";
|
||||||
import { ICON_KEYS, SWATCHES } from "../appearance";
|
import { ICON_KEYS, SWATCHES } from "../appearance";
|
||||||
|
import { KAKEIBO_GROUPS } from "../kakeibo";
|
||||||
|
|
||||||
export const CATEGORY_KINDS = ["expense", "income"] as const;
|
export const CATEGORY_KINDS = ["expense", "income"] as const;
|
||||||
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
|
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
|
||||||
@@ -14,6 +15,13 @@ export const categoryFormSchema = z.object({
|
|||||||
.transform((s) => (s === "" ? null : s)),
|
.transform((s) => (s === "" ? null : s)),
|
||||||
icon: z.enum(ICON_KEYS),
|
icon: z.enum(ICON_KEYS),
|
||||||
color: z.enum(SWATCHES),
|
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>;
|
export type CategoryFormInput = z.input<typeof categoryFormSchema>;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tr } from "../../i18n/tr";
|
|||||||
import { parseMoney } from "../money";
|
import { parseMoney } from "../money";
|
||||||
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
||||||
import { MAX_INSTALLMENTS } from "../card-periods";
|
import { MAX_INSTALLMENTS } from "../card-periods";
|
||||||
|
import { KAKEIBO_GROUPS } from "../kakeibo";
|
||||||
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
|
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
|
||||||
|
|
||||||
const v = tr.validation;
|
const v = tr.validation;
|
||||||
@@ -28,10 +29,17 @@ const positiveMoney = z
|
|||||||
return minor;
|
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({
|
const splitSchema = z.object({
|
||||||
categoryId: objectId.refine(Boolean, t.selectCategory),
|
categoryId: objectId.refine(Boolean, t.selectCategory),
|
||||||
amount: positiveMoney,
|
amount: positiveMoney,
|
||||||
note: z.string().trim().max(120).default(""),
|
note: z.string().trim().max(120).default(""),
|
||||||
|
kakeiboGroup: kakeiboOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +56,7 @@ export const transactionFormSchema = z
|
|||||||
toAccountId: optionalId,
|
toAccountId: optionalId,
|
||||||
categoryId: optionalId,
|
categoryId: optionalId,
|
||||||
splits: z.array(splitSchema).max(MAX_SPLITS).default([]),
|
splits: z.array(splitSchema).max(MAX_SPLITS).default([]),
|
||||||
|
kakeiboGroup: kakeiboOverride,
|
||||||
payee: z.string().trim().max(80).default(""),
|
payee: z.string().trim().max(80).default(""),
|
||||||
description: z.string().trim().max(500).default(""),
|
description: z.string().trim().max(500).default(""),
|
||||||
tags: z
|
tags: z
|
||||||
|
|||||||
Reference in New Issue
Block a user