feat(budget): aylık bütçe motoru, limitler ve ay sonu tahmini
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
import { BudgetModel } from "@/models/budget";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "010-budgets",
|
||||||
|
description: "budgets index'i (hane × ay benzersiz)",
|
||||||
|
async up() {
|
||||||
|
await BudgetModel.createIndexes();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -9,10 +9,11 @@ import { migration as m006 } from "./006-card-statements";
|
|||||||
import { migration as m007 } from "./007-contacts";
|
import { migration as m007 } from "./007-contacts";
|
||||||
import { migration as m008 } from "./008-in-kind";
|
import { migration as m008 } from "./008-in-kind";
|
||||||
import { migration as m009 } from "./009-assets";
|
import { migration as m009 } from "./009-assets";
|
||||||
|
import { migration as m010 } from "./010-budgets";
|
||||||
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. */
|
||||||
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009];
|
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009, m010];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import { budgetLimitsSchema, budgetMonthSchema } from "@/shared/schemas/budget";
|
||||||
|
import { budgetFromActuals, copyPreviousBudget, saveBudget } from "./service";
|
||||||
|
|
||||||
|
function fieldErrors(error: z.ZodError) {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
const key = issue.path.join(".");
|
||||||
|
if (key && !out[key]) out[key] = issue.message;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
input: unknown,
|
||||||
|
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<unknown>>,
|
||||||
|
): Promise<ActionResult<unknown>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const parsed = schema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||||
|
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||||
|
if (result.ok) revalidatePath("/", "layout");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBudgetAction(input: unknown) {
|
||||||
|
return run(budgetLimitsSchema, input, saveBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyPreviousBudgetAction(month: unknown) {
|
||||||
|
return run(budgetMonthSchema, month, copyPreviousBudget);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function budgetFromActualsAction(month: unknown) {
|
||||||
|
return run(budgetMonthSchema, month, budgetFromActuals);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { createCategory } from "@/features/categories/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { BudgetModel } from "@/models/budget";
|
||||||
|
import { HouseholdModel } from "@/models/household";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { budgetPeriod, shiftMonth } from "@/shared/budget";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import { addDays } from "@/shared/recurrence";
|
||||||
|
import { budgetLimitsSchema } from "@/shared/schemas/budget";
|
||||||
|
import { categoryFormSchema } from "@/shared/schemas/category";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { budgetFromActuals, copyPreviousBudget, getBudget, saveBudget } from "./service";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let emre = "";
|
||||||
|
let zeynep = "";
|
||||||
|
let fatma = "";
|
||||||
|
// Seed verisinin olmadığı geçmiş bir ay: toplamlar yalnızca bu testin işlemlerinden oluşur.
|
||||||
|
const M = shiftMonth(todayIstanbul().slice(0, 7), -24);
|
||||||
|
const P = shiftMonth(M, -1);
|
||||||
|
const cat = { ev: "", kira: "", aidat: "", keyif: "" };
|
||||||
|
type CatKey = keyof typeof cat;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
emre = ids.user("emre");
|
||||||
|
zeynep = ids.user("zeynep");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
const mk = async (key: CatKey, name: string, parent = "") => {
|
||||||
|
const r = await createCategory(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
categoryFormSchema.parse({
|
||||||
|
kind: "expense",
|
||||||
|
name,
|
||||||
|
parentId: parent,
|
||||||
|
icon: "tag",
|
||||||
|
color: "blue",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
cat[key] = r.ok ? r.data.id : "";
|
||||||
|
};
|
||||||
|
await mk("ev", "Test Ev");
|
||||||
|
await mk("kira", "Test Kira", cat.ev);
|
||||||
|
await mk("aidat", "Test Aidat", cat.ev);
|
||||||
|
await mk("keyif", "Test Keyif");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
const acc = (name: string) =>
|
||||||
|
AccountModel.findOne({ householdId: cakir, name })
|
||||||
|
.lean()
|
||||||
|
.then((a) => a!._id);
|
||||||
|
|
||||||
|
async function expense(input: {
|
||||||
|
date: string;
|
||||||
|
amountMinor: number;
|
||||||
|
category?: string;
|
||||||
|
splits?: { category: string; amountMinor: number }[];
|
||||||
|
account?: string;
|
||||||
|
status?: "posted" | "planned";
|
||||||
|
recurring?: boolean;
|
||||||
|
installment?: { planId: Types.ObjectId; index: number; count: number };
|
||||||
|
}) {
|
||||||
|
const actor = new Types.ObjectId(ayse);
|
||||||
|
const splits = (input.splits ?? []).map((s) => ({
|
||||||
|
categoryId: new Types.ObjectId(s.category),
|
||||||
|
amountMinor: s.amountMinor,
|
||||||
|
}));
|
||||||
|
await TransactionModel.create({
|
||||||
|
householdId: new Types.ObjectId(cakir),
|
||||||
|
type: "expense",
|
||||||
|
status: input.status ?? "posted",
|
||||||
|
date: istanbulDayStart(input.date)!,
|
||||||
|
accountId: await acc(input.account ?? "Cüzdan"),
|
||||||
|
amountMinor: input.amountMinor,
|
||||||
|
categoryId: input.category ? new Types.ObjectId(input.category) : null,
|
||||||
|
splits,
|
||||||
|
effectiveCategoryIds: input.category
|
||||||
|
? [new Types.ObjectId(input.category)]
|
||||||
|
: splits.map((s) => s.categoryId),
|
||||||
|
recurringRuleId: input.recurring ? new Types.ObjectId() : null,
|
||||||
|
occurrenceIndex: input.recurring ? 0 : null,
|
||||||
|
installment: input.installment ?? null,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const limits = (month: string, list: Partial<Record<CatKey, string>>) =>
|
||||||
|
budgetLimitsSchema.parse({
|
||||||
|
month,
|
||||||
|
limits: Object.entries(list).map(([k, amount]) => ({ categoryId: cat[k as CatKey], amount })),
|
||||||
|
});
|
||||||
|
const line = async (key: CatKey, month = M, user = ayse) =>
|
||||||
|
(await getBudget(user, cakir, month))!.lines.find((l) => l.categoryId === cat[key]);
|
||||||
|
|
||||||
|
describe("aylık bütçe", () => {
|
||||||
|
const { from, to } = budgetPeriod(M);
|
||||||
|
|
||||||
|
it("limitler; üst limit alt kategorileri kapsar; bölünmüş işlem kalem bazında; planlı ayrı", async () => {
|
||||||
|
await expense({ date: from, amountMinor: 15_000_00, category: cat.kira, recurring: true });
|
||||||
|
await expense({
|
||||||
|
date: addDays(from, 5),
|
||||||
|
amountMinor: 3_000_00,
|
||||||
|
splits: [
|
||||||
|
{ category: cat.aidat, amountMinor: 1_200_00 },
|
||||||
|
{ category: cat.keyif, amountMinor: 1_800_00 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expense({ date: to, amountMinor: 500_00, category: cat.aidat, status: "planned" });
|
||||||
|
expect((await saveBudget(ayse, cakir, limits(M, { ev: "17.000", kira: "15.000" }))).ok).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const b = (await getBudget(ayse, cakir, M))!;
|
||||||
|
expect(b.hasBudget).toBe(true);
|
||||||
|
// Geçmiş ay: tüm günler geçti.
|
||||||
|
expect(b.elapsedDays).toBe(b.totalDays);
|
||||||
|
expect(b.totals.limitMinor).toBe(17_000_00);
|
||||||
|
expect(b.totals.actualMinor).toBe(18_000_00);
|
||||||
|
expect(b.totals.unbudgetedMinor).toBe(1_800_00);
|
||||||
|
expect(await line("ev")).toMatchObject({
|
||||||
|
actualMinor: 16_200_00,
|
||||||
|
plannedMinor: 500_00,
|
||||||
|
forecastMinor: 16_700_00,
|
||||||
|
status: "warning",
|
||||||
|
forecastOver: false,
|
||||||
|
});
|
||||||
|
expect(await line("kira")).toMatchObject({ status: "over", remainingMinor: 0 });
|
||||||
|
expect(await line("keyif")).toMatchObject({ limitMinor: null, actualMinor: 1_800_00 });
|
||||||
|
// Boş limit kaldırılır.
|
||||||
|
await saveBudget(ayse, cakir, limits(M, { ev: "17.000", kira: "" }));
|
||||||
|
expect((await line("kira"))!.limitMinor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gizli hesap harcaması hane ayarına göre", async () => {
|
||||||
|
const actor = new Types.ObjectId(emre);
|
||||||
|
const secret = await AccountModel.create({
|
||||||
|
householdId: new Types.ObjectId(cakir),
|
||||||
|
name: "Emre Gizli Test",
|
||||||
|
type: "cash",
|
||||||
|
openingBalanceMinor: 0,
|
||||||
|
openingDate: istanbulDayStart(addDays(from, -10))!,
|
||||||
|
isPrivate: true,
|
||||||
|
ownerUserId: actor,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
await expense({
|
||||||
|
date: addDays(from, 2),
|
||||||
|
amountMinor: 700_00,
|
||||||
|
category: cat.keyif,
|
||||||
|
account: "Emre Gizli Test",
|
||||||
|
});
|
||||||
|
expect((await line("keyif"))!.actualMinor).toBe(2_500_00);
|
||||||
|
const b = (await getBudget(ayse, cakir, M))!;
|
||||||
|
expect(b.includesHidden).toBe(true);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.includePrivateAccountsInTotals": false } },
|
||||||
|
);
|
||||||
|
expect((await line("keyif"))!.actualMinor).toBe(1_800_00);
|
||||||
|
// Sahibi her zaman görür.
|
||||||
|
expect((await line("keyif", M, emre))!.actualMinor).toBe(2_500_00);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.includePrivateAccountsInTotals": true } },
|
||||||
|
);
|
||||||
|
expect(secret).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("taksit modu: her taksit kendi ayına veya tamamı alış ayına", async () => {
|
||||||
|
const planId = new Types.ObjectId();
|
||||||
|
const next = budgetPeriod(shiftMonth(M, 1));
|
||||||
|
await expense({
|
||||||
|
date: addDays(from, 10),
|
||||||
|
amountMinor: 1_000_00,
|
||||||
|
category: cat.aidat,
|
||||||
|
installment: { planId, index: 1, count: 2 },
|
||||||
|
});
|
||||||
|
await expense({
|
||||||
|
date: addDays(next.from, 10),
|
||||||
|
amountMinor: 1_000_00,
|
||||||
|
category: cat.aidat,
|
||||||
|
installment: { planId, index: 2, count: 2 },
|
||||||
|
status: "planned",
|
||||||
|
});
|
||||||
|
const before = (await line("aidat"))!.actualMinor;
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.installmentBudgetMode": "purchase_month" } },
|
||||||
|
);
|
||||||
|
expect((await line("aidat"))!.actualMinor).toBe(before + 1_000_00);
|
||||||
|
// Tamamı alış ayında sayıldığı için sonraki ayda görünmez.
|
||||||
|
expect((await line("aidat", shiftMonth(M, 1)))?.actualMinor ?? 0).toBe(0);
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.installmentBudgetMode": "per_installment" } },
|
||||||
|
);
|
||||||
|
// Her taksit kendi ayında (geçmiş ay olduğu için vadesi gelen taksit gerçekleşmiştir).
|
||||||
|
expect((await line("aidat", shiftMonth(M, 1)))!.actualMinor).toBe(1_000_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bütçe ayı başlangıç günü dönemi kaydırır", async () => {
|
||||||
|
await HouseholdModel.updateOne(
|
||||||
|
{ _id: cakir },
|
||||||
|
{ $set: { "settings.budgetMonthStartDay": 15 } },
|
||||||
|
);
|
||||||
|
const b = (await getBudget(ayse, cakir, M))!;
|
||||||
|
expect(b.period).toEqual(budgetPeriod(M, 15));
|
||||||
|
// Ayın 1'indeki kira artık önceki bütçe ayında.
|
||||||
|
expect((await line("kira"))?.actualMinor ?? 0).toBe(0);
|
||||||
|
await HouseholdModel.updateOne({ _id: cakir }, { $set: { "settings.budgetMonthStartDay": 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("önceki aydan kopyala ve önceki ayın harcamalarından oluştur", async () => {
|
||||||
|
const N = shiftMonth(M, 1);
|
||||||
|
expect((await copyPreviousBudget(ayse, cakir, P)).ok).toBe(false);
|
||||||
|
expect((await copyPreviousBudget(ayse, cakir, N)).ok).toBe(true);
|
||||||
|
expect((await getBudget(ayse, cakir, N))!.editor.limits[cat.ev]).toBe(17_000_00);
|
||||||
|
// M'nin harcamaları: Ev kökü 16.200 + taksit 1.000 → 17.200; Keyif 2.500 (gizli dahil).
|
||||||
|
expect((await budgetFromActuals(ayse, cakir, N)).ok).toBe(true);
|
||||||
|
const l = (await BudgetModel.findOne({ householdId: cakir, month: N }).lean())!.limits;
|
||||||
|
const get = (k: CatKey) => l.find((x) => String(x.categoryId) === cat[k])?.amountMinor;
|
||||||
|
expect(get("ev")).toBe(17_200_00);
|
||||||
|
expect(get("keyif")).toBe(2_500_00);
|
||||||
|
expect(get("kira")).toBeUndefined();
|
||||||
|
expect((await budgetFromActuals(ayse, cakir, shiftMonth(M, -5))).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("yetki: izleyici yazamaz; başka hane göremez; geçersiz kategori reddedilir", async () => {
|
||||||
|
expect((await getBudget(zeynep, cakir, M))!.canWrite).toBe(false);
|
||||||
|
expect((await saveBudget(zeynep, cakir, limits(M, { ev: "1" }))).ok).toBe(false);
|
||||||
|
expect(await getBudget(fatma, cakir, M)).toBeNull();
|
||||||
|
const annemler = ids.household("Annemler");
|
||||||
|
expect((await saveBudget(fatma, annemler, limits(M, { ev: "1" }))).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { Types, type PipelineStage } from "mongoose";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { loadContext, type Ctx } from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { BudgetModel } from "@/models/budget";
|
||||||
|
import { HouseholdModel } from "@/models/household";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
|
import {
|
||||||
|
budgetPeriod,
|
||||||
|
computeBudget,
|
||||||
|
daysBetween,
|
||||||
|
periodOfDate,
|
||||||
|
roundUpLimit,
|
||||||
|
shiftMonth,
|
||||||
|
type BudgetLine,
|
||||||
|
type BudgetTotals,
|
||||||
|
type CategoryAmounts,
|
||||||
|
} from "@/shared/budget";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import type { BudgetLimitsForm } from "@/shared/schemas/budget";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aylık bütçe (Bölüm 6.9). Okuma: viewer; yazma: member.
|
||||||
|
*
|
||||||
|
* Gerçekleşen: dönemdeki gerçekleşmiş (posted) giderler; bölünmüş işlemler kalem bazında.
|
||||||
|
* Planlı: dönemdeki planlı giderler (düzenli işlem tekrarları, gelecek taksitler).
|
||||||
|
* Taksit modu (hane ayarı): her taksit kendi ayına (varsayılan) veya tamamı alış ayına.
|
||||||
|
* Başkasının "yalnızca ben" hesabından giderler hane ayarına göre (yalnızca tutar) sayılır.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.budget;
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
type Settings = {
|
||||||
|
startDay: number;
|
||||||
|
installmentMode: "per_installment" | "purchase_month";
|
||||||
|
includePrivate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function settingsOf(householdId: Types.ObjectId): Promise<Settings> {
|
||||||
|
const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean();
|
||||||
|
return {
|
||||||
|
startDay: h?.settings?.budgetMonthStartDay ?? 1,
|
||||||
|
installmentMode: h?.settings?.installmentBudgetMode ?? "per_installment",
|
||||||
|
includePrivate: h?.settings?.includePrivateAccountsInTotals ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kategori bazında (kendi) sabit/değişken gerçekleşen ve planlı gider. */
|
||||||
|
export async function expenseAmounts(
|
||||||
|
ctx: Ctx,
|
||||||
|
s: Settings,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): Promise<Map<string, CategoryAmounts>> {
|
||||||
|
const start = istanbulDayStart(from)!;
|
||||||
|
const end = new Date(istanbulDayStart(to)!.getTime() + DAY_MS);
|
||||||
|
const base: Record<string, unknown> = {
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
type: "expense",
|
||||||
|
deletedAt: null,
|
||||||
|
};
|
||||||
|
if (!s.includePrivate && ctx.hiddenAccountIds.length)
|
||||||
|
base.accountId = { $nin: ctx.hiddenAccountIds };
|
||||||
|
|
||||||
|
const itemsStage: PipelineStage = {
|
||||||
|
$project: {
|
||||||
|
status: 1,
|
||||||
|
fixed: {
|
||||||
|
$or: [{ $ne: ["$recurringRuleId", null] }, { $ne: ["$installment.planId", null] }],
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
$cond: [
|
||||||
|
{ $gt: [{ $size: { $ifNull: ["$splits", []] } }, 0] },
|
||||||
|
"$splits",
|
||||||
|
[{ categoryId: "$categoryId", amountMinor: "$amountMinor" }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const group: PipelineStage[] = [
|
||||||
|
itemsStage,
|
||||||
|
{ $unwind: "$items" },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: { c: "$items.categoryId", s: "$status", f: "$fixed" },
|
||||||
|
sum: { $sum: "$items.amountMinor" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const match: Record<string, unknown> = {
|
||||||
|
...base,
|
||||||
|
status: { $in: ["posted", "planned"] },
|
||||||
|
date: { $gte: start, $lt: end },
|
||||||
|
};
|
||||||
|
if (s.installmentMode === "purchase_month") match["installment.planId"] = { $in: [null] };
|
||||||
|
const rows = await TransactionModel.aggregate<{
|
||||||
|
_id: { c: Types.ObjectId | null; s: string; f: boolean };
|
||||||
|
sum: number;
|
||||||
|
}>([{ $match: match }, ...group]);
|
||||||
|
|
||||||
|
const out = new Map<string, CategoryAmounts>();
|
||||||
|
const add = (cat: string, k: keyof CategoryAmounts, v: number) => {
|
||||||
|
const cur = out.get(cat) ?? { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 };
|
||||||
|
cur[k] += v;
|
||||||
|
out.set(cat, cur);
|
||||||
|
};
|
||||||
|
for (const r of rows) {
|
||||||
|
const cat = r._id.c ? String(r._id.c) : "";
|
||||||
|
if (r._id.s === "planned") add(cat, "plannedMinor", r.sum);
|
||||||
|
else add(cat, r._id.f ? "fixedMinor" : "variableMinor", r.sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tamamı alış ayına: ilk taksiti bu dönemde olan planların tüm taksitleri gerçekleşmiş sayılır.
|
||||||
|
if (s.installmentMode === "purchase_month") {
|
||||||
|
const plans = await TransactionModel.aggregate<{
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
first: Date;
|
||||||
|
items: { categoryId: Types.ObjectId | null; amountMinor: number }[][];
|
||||||
|
}>([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
...base,
|
||||||
|
status: { $in: ["posted", "planned"] },
|
||||||
|
"installment.planId": { $ne: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: "$installment.planId",
|
||||||
|
first: { $min: "$date" },
|
||||||
|
items: {
|
||||||
|
$push: {
|
||||||
|
$cond: [
|
||||||
|
{ $gt: [{ $size: { $ifNull: ["$splits", []] } }, 0] },
|
||||||
|
"$splits",
|
||||||
|
[{ categoryId: "$categoryId", amountMinor: "$amountMinor" }],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $match: { first: { $gte: start, $lt: end } } },
|
||||||
|
]);
|
||||||
|
for (const p of plans)
|
||||||
|
for (const list of p.items)
|
||||||
|
for (const it of list)
|
||||||
|
add(it.categoryId ? String(it.categoryId) : "", "fixedMinor", it.amountMinor);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BudgetLineView = BudgetLine & { icon: IconKey; color: Swatch; path: string };
|
||||||
|
|
||||||
|
export type BudgetView = {
|
||||||
|
month: string;
|
||||||
|
currentMonth: string;
|
||||||
|
period: { from: string; to: string };
|
||||||
|
elapsedDays: number;
|
||||||
|
totalDays: number;
|
||||||
|
lines: BudgetLineView[];
|
||||||
|
totals: BudgetTotals;
|
||||||
|
hasBudget: boolean;
|
||||||
|
/** Limit düzenleyici: etkin gider kategorileri (ağaç sırası) ve mevcut limitler. */
|
||||||
|
editor: {
|
||||||
|
categories: { id: string; name: string; depth: number }[];
|
||||||
|
limits: Record<string, number>;
|
||||||
|
};
|
||||||
|
hasPrevious: boolean;
|
||||||
|
includesHidden: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function expenseCategories(ctx: Ctx) {
|
||||||
|
return ctx.flatCategories.filter((c) => c.kind === "expense");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBudget(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month?: string,
|
||||||
|
): Promise<BudgetView | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const s = await settingsOf(ctx.m.householdId);
|
||||||
|
const today = todayIstanbul();
|
||||||
|
const currentMonth = periodOfDate(today, s.startDay);
|
||||||
|
const m = month ?? currentMonth;
|
||||||
|
const period = budgetPeriod(m, s.startDay);
|
||||||
|
const totalDays = daysBetween(period.from, period.to);
|
||||||
|
const elapsedDays =
|
||||||
|
today < period.from ? 0 : today > period.to ? totalDays : daysBetween(period.from, today);
|
||||||
|
|
||||||
|
const [doc, prev, amounts] = await Promise.all([
|
||||||
|
BudgetModel.findOne({ householdId: ctx.m.householdId, month: m }).lean(),
|
||||||
|
BudgetModel.exists({
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
month: shiftMonth(m, -1),
|
||||||
|
"limits.0": { $exists: true },
|
||||||
|
}),
|
||||||
|
expenseAmounts(ctx, s, period.from, period.to),
|
||||||
|
]);
|
||||||
|
const limits = new Map((doc?.limits ?? []).map((l) => [String(l.categoryId), l.amountMinor]));
|
||||||
|
const cats = expenseCategories(ctx);
|
||||||
|
// Arşivli kategori yalnızca limiti veya harcaması varsa görünür.
|
||||||
|
const visibleCats = cats.filter((c) => !c.archived || limits.has(c.id) || amounts.has(c.id));
|
||||||
|
const known = new Set(visibleCats.map((c) => c.id));
|
||||||
|
// Kategorisiz / bilinmeyen kategorideki gider "Kategorisiz" satırında toplanır.
|
||||||
|
const orphan = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 };
|
||||||
|
for (const [id, a] of amounts)
|
||||||
|
if (!known.has(id)) {
|
||||||
|
orphan.fixedMinor += a.fixedMinor;
|
||||||
|
orphan.variableMinor += a.variableMinor;
|
||||||
|
orphan.plannedMinor += a.plannedMinor;
|
||||||
|
}
|
||||||
|
const ORPHAN = "__none__";
|
||||||
|
const inputCats = visibleCats.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
parentId: c.parentId,
|
||||||
|
name: c.name,
|
||||||
|
order: c.order,
|
||||||
|
}));
|
||||||
|
if (orphan.fixedMinor + orphan.variableMinor + orphan.plannedMinor > 0) {
|
||||||
|
inputCats.push({ id: ORPHAN, parentId: null, name: t.uncategorized, order: 1e9 });
|
||||||
|
amounts.set(ORPHAN, orphan);
|
||||||
|
}
|
||||||
|
const { lines, totals } = computeBudget({
|
||||||
|
categories: inputCats,
|
||||||
|
limits,
|
||||||
|
amounts,
|
||||||
|
elapsedDays,
|
||||||
|
totalDays,
|
||||||
|
});
|
||||||
|
const editorCats = cats.filter((c) => !c.archived);
|
||||||
|
// Düzenleyici için ağaç sırası ve derinlik (tüm etkin kategoriler, limit 0 ile).
|
||||||
|
const ordered = computeBudget({
|
||||||
|
categories: editorCats.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
parentId: c.parentId,
|
||||||
|
name: c.name,
|
||||||
|
order: c.order,
|
||||||
|
})),
|
||||||
|
limits: new Map(editorCats.map((c) => [c.id, 0])),
|
||||||
|
amounts: new Map(),
|
||||||
|
elapsedDays: 0,
|
||||||
|
totalDays: 1,
|
||||||
|
}).lines;
|
||||||
|
|
||||||
|
return {
|
||||||
|
month: m,
|
||||||
|
currentMonth,
|
||||||
|
period,
|
||||||
|
elapsedDays,
|
||||||
|
totalDays,
|
||||||
|
lines: lines.map((l) => {
|
||||||
|
const c = ctx.categoryMap.get(l.categoryId);
|
||||||
|
return {
|
||||||
|
...l,
|
||||||
|
icon: (c?.icon ?? "tag") as IconKey,
|
||||||
|
color: (c?.color ?? "slate") as Swatch,
|
||||||
|
path: c?.path ?? l.name,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
totals,
|
||||||
|
hasBudget: limits.size > 0,
|
||||||
|
editor: {
|
||||||
|
categories: ordered.map((l) => ({ id: l.categoryId, name: l.name, depth: l.depth })),
|
||||||
|
limits: Object.fromEntries(limits),
|
||||||
|
},
|
||||||
|
hasPrevious: Boolean(prev),
|
||||||
|
includesHidden: s.includePrivate && ctx.hiddenAccountIds.length > 0,
|
||||||
|
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLimits(
|
||||||
|
ctx: Ctx,
|
||||||
|
actorId: string,
|
||||||
|
month: string,
|
||||||
|
limits: { categoryId: string; amountMinor: number }[],
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const valid = new Set(
|
||||||
|
expenseCategories(ctx)
|
||||||
|
.filter((c) => !c.archived)
|
||||||
|
.map((c) => c.id),
|
||||||
|
);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const l of limits) {
|
||||||
|
if (!valid.has(l.categoryId) || seen.has(l.categoryId)) return fail(t.errors.category);
|
||||||
|
seen.add(l.categoryId);
|
||||||
|
}
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await BudgetModel.updateOne(
|
||||||
|
{ householdId: ctx.m.householdId, month },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
limits: limits.map((l) => ({
|
||||||
|
categoryId: new Types.ObjectId(l.categoryId),
|
||||||
|
amountMinor: l.amountMinor,
|
||||||
|
})),
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
$setOnInsert: { createdBy: actor },
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBudget(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: BudgetLimitsForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
return writeLimits(ctx, actorId, input.month, input.limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Önceki ayın limitlerini kopyalar (arşivlenmiş kategoriler atlanır). */
|
||||||
|
export async function copyPreviousBudget(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
month: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const prev = await BudgetModel.findOne({
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
month: shiftMonth(month, -1),
|
||||||
|
}).lean();
|
||||||
|
if (!prev?.limits.length) return fail(t.errors.noPrevious);
|
||||||
|
const active = new Set(
|
||||||
|
expenseCategories(ctx)
|
||||||
|
.filter((c) => !c.archived)
|
||||||
|
.map((c) => c.id),
|
||||||
|
);
|
||||||
|
return writeLimits(
|
||||||
|
ctx,
|
||||||
|
actorId,
|
||||||
|
month,
|
||||||
|
prev.limits
|
||||||
|
.map((l) => ({ categoryId: String(l.categoryId), amountMinor: l.amountMinor }))
|
||||||
|
.filter((l) => active.has(l.categoryId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Önceki ayın gerçekleşen giderlerinden limit: ana kategori (kök) bazında toplam, 100 TL'ye
|
||||||
|
* yukarı yuvarlanır. Kategorisiz gider atlanır.
|
||||||
|
*/
|
||||||
|
export async function budgetFromActuals(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
month: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const s = await settingsOf(ctx.m.householdId);
|
||||||
|
const period = budgetPeriod(shiftMonth(month, -1), s.startDay);
|
||||||
|
const amounts = await expenseAmounts(ctx, s, period.from, period.to);
|
||||||
|
const cats = expenseCategories(ctx);
|
||||||
|
const byId = new Map(cats.map((c) => [c.id, c]));
|
||||||
|
const rootOf = (id: string) => {
|
||||||
|
let cur = byId.get(id);
|
||||||
|
let guard = 0;
|
||||||
|
while (cur?.parentId && byId.has(cur.parentId) && guard++ < 6) cur = byId.get(cur.parentId);
|
||||||
|
return cur;
|
||||||
|
};
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
for (const [id, a] of amounts) {
|
||||||
|
const root = rootOf(id);
|
||||||
|
if (!root || root.archived) continue;
|
||||||
|
totals.set(root.id, (totals.get(root.id) ?? 0) + a.fixedMinor + a.variableMinor);
|
||||||
|
}
|
||||||
|
const limits = [...totals]
|
||||||
|
.filter(([, v]) => v > 0)
|
||||||
|
.map(([categoryId, v]) => ({ categoryId, amountMinor: roundUpLimit(v) }));
|
||||||
|
if (!limits.length) return fail(t.errors.noPreviousSpending);
|
||||||
|
return writeLimits(ctx, actorId, month, limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard uyarısı: bu ayın %80'i geçilmiş veya aşılmış limitli kategori sayısı. */
|
||||||
|
export async function countBudgetWarnings(userId: string, householdId: string): Promise<number> {
|
||||||
|
const b = await getBudget(userId, householdId);
|
||||||
|
if (!b?.hasBudget) return 0;
|
||||||
|
return b.lines.filter((l) => l.status === "warning" || l.status === "over").length;
|
||||||
|
}
|
||||||
@@ -804,6 +804,77 @@ export const tr = {
|
|||||||
noAccountForGift: "Hediye hareketinde hesap seçilmez.",
|
noAccountForGift: "Hediye hareketinde hesap seçilmez.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
budget: {
|
||||||
|
title: "Bütçe",
|
||||||
|
description:
|
||||||
|
"Aylık kategori limitleri, gerçekleşen harcama ve ay sonu tahmini. Üst kategori limiti alt kategorileri kapsar.",
|
||||||
|
prevMonth: "Önceki ay",
|
||||||
|
nextMonth: "Sonraki ay",
|
||||||
|
thisMonth: "Bu ay",
|
||||||
|
period: (from: string, to: string) => `${from} – ${to}`,
|
||||||
|
totals: {
|
||||||
|
limit: "Bütçe",
|
||||||
|
actual: "Harcanan",
|
||||||
|
remaining: "Kalan",
|
||||||
|
over: "Aşım",
|
||||||
|
forecast: "Ay sonu tahmini",
|
||||||
|
planned: (amount: string) => `Planlı: ${amount}`,
|
||||||
|
unbudgeted: "Bütçesiz harcama",
|
||||||
|
monthProgress: (day: number, total: number, pct: string) =>
|
||||||
|
`Ayın ${day}/${total}. günü (%${pct}).`,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
ok: "Yolunda",
|
||||||
|
warning: "%80 geçildi",
|
||||||
|
over: "Limit aşıldı",
|
||||||
|
full: "Limit doldu",
|
||||||
|
none: "Limitsiz",
|
||||||
|
covered: "Üst kategori limitinde",
|
||||||
|
forecastOver: "Tahmin limiti aşıyor",
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
ofLimit: (actual: string, limit: string) => `${actual} / ${limit}`,
|
||||||
|
remaining: (amount: string) => `${amount} kaldı`,
|
||||||
|
over: (amount: string) => `${amount} aşıldı`,
|
||||||
|
forecast: (amount: string) => `Tahmin ${amount}`,
|
||||||
|
planned: (amount: string) => `Planlı ${amount}`,
|
||||||
|
},
|
||||||
|
uncategorized: "Kategorisiz",
|
||||||
|
empty: "Bu ay için bütçe yok.",
|
||||||
|
emptyHint:
|
||||||
|
"Limitleri elle girebilir, önceki ayın bütçesini kopyalayabilir veya önceki ayın harcamalarından oluşturabilirsiniz.",
|
||||||
|
noSpending: "Bu ay henüz gider yok.",
|
||||||
|
edit: "Limitleri düzenle",
|
||||||
|
editTitle: (month: string) => `Bütçe limitleri · ${month}`,
|
||||||
|
editHint:
|
||||||
|
"Boş bırakılan kategori limitsizdir. Alt kategori limiti üst kategorinin içinde bir alt sınırdır.",
|
||||||
|
editTotal: (amount: string) => `Toplam bütçe: ${amount}`,
|
||||||
|
copyPrevious: "Önceki aydan kopyala",
|
||||||
|
fromActuals: "Önceki ayın harcamalarından oluştur",
|
||||||
|
fromActualsHint:
|
||||||
|
"Ana kategorilerin önceki ay harcaması 100 TL'ye yukarı yuvarlanarak limit olur.",
|
||||||
|
replaceTitle: "Mevcut limitler değiştirilsin mi?",
|
||||||
|
replaceDescription: "Bu ayın tüm limitleri seçilen kaynakla değiştirilir.",
|
||||||
|
replace: "Değiştir",
|
||||||
|
includesHidden:
|
||||||
|
"Hane ayarı gereği diğer üyelerin gizli hesaplarından yapılan harcamalar dahil.",
|
||||||
|
warnings: (n: number) => `${n} kategoride bütçenin %80'i geçildi veya limit aşıldı.`,
|
||||||
|
goToBudget: "Bütçeye git",
|
||||||
|
toast: {
|
||||||
|
saved: "Bütçe kaydedildi.",
|
||||||
|
copied: "Önceki ayın limitleri kopyalandı.",
|
||||||
|
created: "Limitler önceki ayın harcamalarından oluşturuldu.",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
noPrevious: "Önceki ay için bütçe yok.",
|
||||||
|
noPreviousSpending: "Önceki ayda gider yok.",
|
||||||
|
category: "Geçersiz veya arşivlenmiş kategori.",
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
month: "Geçersiz ay",
|
||||||
|
category: "Kategori seçin",
|
||||||
|
},
|
||||||
|
},
|
||||||
prices: {
|
prices: {
|
||||||
title: "Fiyat tablosu",
|
title: "Fiyat tablosu",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aylık bütçe (Bölüm 6.9): hane × bütçe ayı ("YYYY-MM") başına kategori limitleri.
|
||||||
|
* Ayın sınırları hane ayarı `budgetMonthStartDay`'e göre hesaplanır (belgede saklanmaz).
|
||||||
|
*/
|
||||||
|
const limitSchema = new Schema(
|
||||||
|
{
|
||||||
|
categoryId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
amountMinor: { type: Number, required: true, min: 0 },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const budgetSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
month: { type: String, required: true, match: /^\d{4}-(0[1-9]|1[0-2])$/ },
|
||||||
|
limits: { type: [limitSchema], default: [] },
|
||||||
|
seedRef: { type: String, default: null },
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true, collection: "budgets" },
|
||||||
|
);
|
||||||
|
|
||||||
|
budgetSchema.index({ householdId: 1, month: 1 }, { unique: true });
|
||||||
|
|
||||||
|
export type Budget = InferSchemaType<typeof budgetSchema>;
|
||||||
|
|
||||||
|
export const BudgetModel: Model<Budget> =
|
||||||
|
(models.Budget as Model<Budget> | undefined) ?? model<Budget>("Budget", budgetSchema);
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
budgetPeriod,
|
||||||
|
computeBudget,
|
||||||
|
daysBetween,
|
||||||
|
forecast,
|
||||||
|
periodOfDate,
|
||||||
|
roundUpLimit,
|
||||||
|
shiftMonth,
|
||||||
|
statusOf,
|
||||||
|
type CategoryAmounts,
|
||||||
|
} from "./budget";
|
||||||
|
|
||||||
|
describe("bütçe dönemi", () => {
|
||||||
|
it("takvim ayı ve 15'inde başlayan ay", () => {
|
||||||
|
expect(budgetPeriod("2026-11")).toEqual({ from: "2026-11-01", to: "2026-11-30" });
|
||||||
|
expect(budgetPeriod("2026-12", 15)).toEqual({ from: "2026-12-15", to: "2027-01-14" });
|
||||||
|
expect(budgetPeriod("2027-01", 28)).toEqual({ from: "2027-01-28", to: "2027-02-27" });
|
||||||
|
expect(periodOfDate("2026-11-14", 15)).toBe("2026-10");
|
||||||
|
expect(periodOfDate("2026-11-15", 15)).toBe("2026-11");
|
||||||
|
expect(periodOfDate("2027-01-03", 15)).toBe("2026-12");
|
||||||
|
expect(periodOfDate("2026-11-30")).toBe("2026-11");
|
||||||
|
expect(shiftMonth("2026-12", 1)).toBe("2027-01");
|
||||||
|
expect(shiftMonth("2026-01", -1)).toBe("2025-12");
|
||||||
|
expect(daysBetween("2026-11-01", "2026-11-30")).toBe(30);
|
||||||
|
expect(daysBetween("2026-12-15", "2027-01-14")).toBe(31);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("durum eşikleri %80 ve %100", () => {
|
||||||
|
expect(statusOf(79_99, 100_00)).toBe("ok");
|
||||||
|
expect(statusOf(80_00, 100_00)).toBe("warning");
|
||||||
|
expect(statusOf(100_00, 100_00)).toBe("over");
|
||||||
|
expect(statusOf(5, null)).toBe("none");
|
||||||
|
expect(statusOf(1, 0)).toBe("over");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ay sonu tahmini: sabit + planlı + değişken tempo", () => {
|
||||||
|
const a: CategoryAmounts = {
|
||||||
|
fixedMinor: 1_000_00,
|
||||||
|
variableMinor: 3_000_00,
|
||||||
|
plannedMinor: 500_00,
|
||||||
|
};
|
||||||
|
// 10/30 gün geçti: değişken 3.000 → 9.000
|
||||||
|
expect(forecast(a, 10, 30)).toBe(10_500_00);
|
||||||
|
expect(forecast(a, 30, 30)).toBe(4_500_00);
|
||||||
|
expect(forecast(a, 0, 30)).toBe(1_500_00);
|
||||||
|
expect(roundUpLimit(1_234_56)).toBe(1_300_00);
|
||||||
|
expect(roundUpLimit(1_200_00)).toBe(1_200_00);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bütçe toplama", () => {
|
||||||
|
const categories = [
|
||||||
|
{ id: "market", parentId: null, name: "Market", order: 1 },
|
||||||
|
{ id: "faturalar", parentId: null, name: "Faturalar", order: 2 },
|
||||||
|
{ id: "elektrik", parentId: "faturalar", name: "Elektrik", order: 1 },
|
||||||
|
{ id: "su", parentId: "faturalar", name: "Su", order: 2 },
|
||||||
|
{ id: "eglence", parentId: null, name: "Eğlence", order: 3 },
|
||||||
|
{ id: "sinema", parentId: "eglence", name: "Sinema", order: 1 },
|
||||||
|
{ id: "giyim", parentId: null, name: "Giyim", order: 4 },
|
||||||
|
];
|
||||||
|
const amt = (variable: number, fixed = 0, planned = 0): CategoryAmounts => ({
|
||||||
|
fixedMinor: fixed,
|
||||||
|
variableMinor: variable,
|
||||||
|
plannedMinor: planned,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("üst limit alt kategorileri kapsar; alt limit iki kez sayılmaz; bütçesiz harcama", () => {
|
||||||
|
const r = computeBudget({
|
||||||
|
categories,
|
||||||
|
limits: new Map([
|
||||||
|
["market", 10_000_00],
|
||||||
|
["faturalar", 3_000_00],
|
||||||
|
["elektrik", 1_500_00],
|
||||||
|
["sinema", 500_00],
|
||||||
|
]),
|
||||||
|
amounts: new Map([
|
||||||
|
["market", amt(8_500_00)],
|
||||||
|
["elektrik", amt(0, 1_600_00)],
|
||||||
|
["su", amt(0, 400_00, 300_00)],
|
||||||
|
["faturalar", amt(100_00)],
|
||||||
|
["sinema", amt(200_00)],
|
||||||
|
["eglence", amt(300_00)],
|
||||||
|
["giyim", amt(1_000_00)],
|
||||||
|
]),
|
||||||
|
elapsedDays: 30,
|
||||||
|
totalDays: 30,
|
||||||
|
});
|
||||||
|
// Toplam limit: market + faturalar + sinema (elektrik faturaların içinde).
|
||||||
|
expect(r.totals.limitMinor).toBe(13_500_00);
|
||||||
|
expect(r.totals.actualMinor).toBe(12_100_00);
|
||||||
|
// Bütçesiz: eğlencenin kendi harcaması + giyim.
|
||||||
|
expect(r.totals.unbudgetedMinor).toBe(1_300_00);
|
||||||
|
const line = (id: string) => r.lines.find((l) => l.categoryId === id)!;
|
||||||
|
expect(line("faturalar")).toMatchObject({
|
||||||
|
actualMinor: 2_100_00,
|
||||||
|
plannedMinor: 300_00,
|
||||||
|
status: "ok",
|
||||||
|
ratioBp: 7000,
|
||||||
|
});
|
||||||
|
expect(line("elektrik")).toMatchObject({ status: "over", remainingMinor: -100_00 });
|
||||||
|
expect(line("market").status).toBe("warning");
|
||||||
|
expect(line("eglence")).toMatchObject({
|
||||||
|
limitMinor: null,
|
||||||
|
actualMinor: 500_00,
|
||||||
|
status: "none",
|
||||||
|
});
|
||||||
|
// Sıra: ağaç sırası, derinlik.
|
||||||
|
expect(r.lines.map((l) => `${l.depth}:${l.categoryId}`)).toEqual([
|
||||||
|
"0:market",
|
||||||
|
"0:faturalar",
|
||||||
|
"1:elektrik",
|
||||||
|
"1:su",
|
||||||
|
"0:eglence",
|
||||||
|
"1:sinema",
|
||||||
|
"0:giyim",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tahmin limiti aşıyor uyarısı; limit yoksa durum none", () => {
|
||||||
|
const r = computeBudget({
|
||||||
|
categories,
|
||||||
|
limits: new Map([["market", 10_000_00]]),
|
||||||
|
amounts: new Map([["market", amt(4_000_00)]]),
|
||||||
|
elapsedDays: 10,
|
||||||
|
totalDays: 30,
|
||||||
|
});
|
||||||
|
expect(r.totals).toMatchObject({ status: "ok", forecastMinor: 12_000_00, forecastOver: true });
|
||||||
|
const empty = computeBudget({
|
||||||
|
categories,
|
||||||
|
limits: new Map(),
|
||||||
|
amounts: new Map(),
|
||||||
|
elapsedDays: 1,
|
||||||
|
totalDays: 30,
|
||||||
|
});
|
||||||
|
expect(empty.totals.status).toBe("none");
|
||||||
|
expect(empty.lines).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { addDays, addMonths } from "./recurrence";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aylık bütçe hesap motoru (Bölüm 6.9). Tutarlar kuruş, tarihler İstanbul günü "YYYY-MM-DD".
|
||||||
|
*
|
||||||
|
* Bütçe ayı hane ayarı `budgetMonthStartDay` ile başlar (Faz 0): başlangıç günü 15 ise
|
||||||
|
* "2026-11" dönemi 15 Kasım – 14 Aralık'tır; 1 ise takvim ayıdır.
|
||||||
|
*
|
||||||
|
* Limitler (Faz 10 kararı): her gider kategorisine verilebilir. Üst kategori limiti alt
|
||||||
|
* kategorilerin harcamasını da kapsar; alt kategori limiti üstün içinde bir alt sınırdır ve
|
||||||
|
* toplam bütçede iki kez sayılmaz.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const WARNING_BP = 8_000;
|
||||||
|
export const OVER_BP = 10_000;
|
||||||
|
|
||||||
|
/** "YYYY-MM" dönemi: ilk ve son gün (ikisi de dahil). */
|
||||||
|
export function budgetPeriod(month: string, startDay = 1): { from: string; to: string } {
|
||||||
|
const from = `${month}-${String(startDay).padStart(2, "0")}`;
|
||||||
|
const to = addDays(addMonths(from, 1, startDay), -1);
|
||||||
|
return { from, to };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tarihin düştüğü bütçe ayı. */
|
||||||
|
export function periodOfDate(date: string, startDay = 1): string {
|
||||||
|
const day = Number(date.slice(8, 10));
|
||||||
|
const month = date.slice(0, 7);
|
||||||
|
return day >= startDay ? month : addMonths(`${month}-01`, -1).slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shiftMonth(month: string, n: number): string {
|
||||||
|
return addMonths(`${month}-01`, n).slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** İki gün arasındaki gün sayısı (ikisi de dahil). */
|
||||||
|
export function daysBetween(from: string, to: string): number {
|
||||||
|
const ms = Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`);
|
||||||
|
return Math.round(ms / 86_400_000) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BudgetStatus = "none" | "ok" | "warning" | "over";
|
||||||
|
|
||||||
|
export function statusOf(actualMinor: number, limitMinor: number | null): BudgetStatus {
|
||||||
|
if (limitMinor === null) return "none";
|
||||||
|
if (limitMinor <= 0) return actualMinor > 0 ? "over" : "ok";
|
||||||
|
const bp = (actualMinor * 10_000) / limitMinor;
|
||||||
|
return bp >= OVER_BP ? "over" : bp >= WARNING_BP ? "warning" : "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BudgetCategoryInput = {
|
||||||
|
id: string;
|
||||||
|
parentId: string | null;
|
||||||
|
name: string;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Kategori bazında kendi (alt kategoriler hariç) tutarlar. */
|
||||||
|
export type CategoryAmounts = {
|
||||||
|
/** Düzenli işlem veya taksitten gelen gerçekleşmiş gider (tempo tahminine katılmaz). */
|
||||||
|
fixedMinor: number;
|
||||||
|
/** Diğer gerçekleşmiş gider. */
|
||||||
|
variableMinor: number;
|
||||||
|
/** Dönemin kalanındaki planlı (henüz gerçekleşmemiş) gider. */
|
||||||
|
plannedMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BudgetLine = {
|
||||||
|
categoryId: string;
|
||||||
|
name: string;
|
||||||
|
depth: number;
|
||||||
|
limitMinor: number | null;
|
||||||
|
/** Kendi limiti yok ama bir üst kategorinin limiti kapsıyor. */
|
||||||
|
covered: boolean;
|
||||||
|
/** Alt kategoriler dahil gerçekleşen. */
|
||||||
|
actualMinor: number;
|
||||||
|
plannedMinor: number;
|
||||||
|
forecastMinor: number;
|
||||||
|
remainingMinor: number | null;
|
||||||
|
ratioBp: number | null;
|
||||||
|
status: BudgetStatus;
|
||||||
|
/** Tahmin limiti aşıyor (henüz aşılmamış olsa da). */
|
||||||
|
forecastOver: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BudgetTotals = {
|
||||||
|
limitMinor: number;
|
||||||
|
actualMinor: number;
|
||||||
|
plannedMinor: number;
|
||||||
|
forecastMinor: number;
|
||||||
|
remainingMinor: number;
|
||||||
|
/** Hiçbir limitin kapsamadığı gerçekleşen gider. */
|
||||||
|
unbudgetedMinor: number;
|
||||||
|
ratioBp: number | null;
|
||||||
|
status: BudgetStatus;
|
||||||
|
forecastOver: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ay sonu tahmini: sabit giderler olduğu gibi + kalan planlı giderler + değişken giderin
|
||||||
|
* şimdiye kadarki günlük hızıyla ay sonuna yansıtılması. Geçmiş ayda tahmin = gerçekleşen;
|
||||||
|
* gelecek ayda yalnızca planlı.
|
||||||
|
*/
|
||||||
|
export function forecast(a: CategoryAmounts, elapsedDays: number, totalDays: number): number {
|
||||||
|
const variable =
|
||||||
|
elapsedDays <= 0
|
||||||
|
? 0
|
||||||
|
: elapsedDays >= totalDays
|
||||||
|
? a.variableMinor
|
||||||
|
: Math.round((a.variableMinor * totalDays) / elapsedDays);
|
||||||
|
return a.fixedMinor + a.plannedMinor + variable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeBudget(input: {
|
||||||
|
categories: BudgetCategoryInput[];
|
||||||
|
limits: Map<string, number>;
|
||||||
|
amounts: Map<string, CategoryAmounts>;
|
||||||
|
elapsedDays: number;
|
||||||
|
totalDays: number;
|
||||||
|
}): { lines: BudgetLine[]; totals: BudgetTotals } {
|
||||||
|
const { categories, limits, amounts, elapsedDays, totalDays } = input;
|
||||||
|
const byId = new Map(categories.map((c) => [c.id, c]));
|
||||||
|
const children = new Map<string, BudgetCategoryInput[]>();
|
||||||
|
const roots: BudgetCategoryInput[] = [];
|
||||||
|
for (const c of categories) {
|
||||||
|
if (c.parentId && byId.has(c.parentId)) {
|
||||||
|
const list = children.get(c.parentId) ?? [];
|
||||||
|
list.push(c);
|
||||||
|
children.set(c.parentId, list);
|
||||||
|
} else roots.push(c);
|
||||||
|
}
|
||||||
|
const sort = (list: BudgetCategoryInput[]) =>
|
||||||
|
list.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, "tr"));
|
||||||
|
const zero: CategoryAmounts = { fixedMinor: 0, variableMinor: 0, plannedMinor: 0 };
|
||||||
|
|
||||||
|
// Alt ağaç toplamları.
|
||||||
|
const subtree = new Map<string, CategoryAmounts>();
|
||||||
|
const sumOf = (c: BudgetCategoryInput, seen: Set<string>): CategoryAmounts => {
|
||||||
|
if (seen.has(c.id)) return zero;
|
||||||
|
seen.add(c.id);
|
||||||
|
const own = amounts.get(c.id) ?? zero;
|
||||||
|
const total = { ...own };
|
||||||
|
for (const k of children.get(c.id) ?? []) {
|
||||||
|
const s = sumOf(k, seen);
|
||||||
|
total.fixedMinor += s.fixedMinor;
|
||||||
|
total.variableMinor += s.variableMinor;
|
||||||
|
total.plannedMinor += s.plannedMinor;
|
||||||
|
}
|
||||||
|
subtree.set(c.id, total);
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const r of roots) sumOf(r, seen);
|
||||||
|
|
||||||
|
const lines: BudgetLine[] = [];
|
||||||
|
let limitTotal = 0;
|
||||||
|
let unbudgeted = 0;
|
||||||
|
const walk = (list: BudgetCategoryInput[], depth: number, covered: boolean) => {
|
||||||
|
for (const c of sort(list)) {
|
||||||
|
const limit = limits.get(c.id) ?? null;
|
||||||
|
const s = subtree.get(c.id) ?? zero;
|
||||||
|
const own = amounts.get(c.id) ?? zero;
|
||||||
|
if (limit !== null && !covered) limitTotal += limit;
|
||||||
|
const isCovered = covered || limit !== null;
|
||||||
|
if (!isCovered) unbudgeted += own.fixedMinor + own.variableMinor;
|
||||||
|
const actual = s.fixedMinor + s.variableMinor;
|
||||||
|
const fc = forecast(s, elapsedDays, totalDays);
|
||||||
|
if (limit !== null || actual > 0 || s.plannedMinor > 0) {
|
||||||
|
lines.push({
|
||||||
|
categoryId: c.id,
|
||||||
|
name: c.name,
|
||||||
|
depth,
|
||||||
|
limitMinor: limit,
|
||||||
|
covered: limit === null && covered,
|
||||||
|
actualMinor: actual,
|
||||||
|
plannedMinor: s.plannedMinor,
|
||||||
|
forecastMinor: fc,
|
||||||
|
remainingMinor: limit === null ? null : limit - actual,
|
||||||
|
ratioBp: limit && limit > 0 ? Math.round((actual * 10_000) / limit) : null,
|
||||||
|
status: statusOf(actual, limit),
|
||||||
|
forecastOver: limit !== null && fc > limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
walk(children.get(c.id) ?? [], depth + 1, isCovered);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(roots, 0, false);
|
||||||
|
|
||||||
|
let actual = 0;
|
||||||
|
let planned = 0;
|
||||||
|
let fc = 0;
|
||||||
|
for (const r of roots) {
|
||||||
|
const s = subtree.get(r.id) ?? zero;
|
||||||
|
actual += s.fixedMinor + s.variableMinor;
|
||||||
|
planned += s.plannedMinor;
|
||||||
|
fc += forecast(s, elapsedDays, totalDays);
|
||||||
|
}
|
||||||
|
const hasLimit = limits.size > 0;
|
||||||
|
return {
|
||||||
|
lines,
|
||||||
|
totals: {
|
||||||
|
limitMinor: limitTotal,
|
||||||
|
actualMinor: actual,
|
||||||
|
plannedMinor: planned,
|
||||||
|
forecastMinor: fc,
|
||||||
|
remainingMinor: limitTotal - actual,
|
||||||
|
unbudgetedMinor: unbudgeted,
|
||||||
|
ratioBp: limitTotal > 0 ? Math.round((actual * 10_000) / limitTotal) : null,
|
||||||
|
status: hasLimit ? statusOf(actual, limitTotal) : "none",
|
||||||
|
forecastOver: hasLimit && fc > limitTotal,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Limit önerisi için yukarı yuvarlama (100 TL'lik adım). */
|
||||||
|
export function roundUpLimit(minor: number, stepMinor = 100_00): number {
|
||||||
|
return Math.ceil(minor / stepMinor) * stepMinor;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { nonNegativeMoneySchema } from "./account";
|
||||||
|
|
||||||
|
/** Bütçe ayı "YYYY-MM". */
|
||||||
|
export const budgetMonthSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-(0[1-9]|1[0-2])$/, tr.budget.validation.month);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limit formu: kategori → tutar metni. Boş veya 0 limit kaldırılır (kategori bütçesiz olur).
|
||||||
|
*/
|
||||||
|
export const budgetLimitsSchema = z.object({
|
||||||
|
month: budgetMonthSchema,
|
||||||
|
limits: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
categoryId: z.string().regex(/^[a-f0-9]{24}$/i, tr.budget.validation.category),
|
||||||
|
amount: nonNegativeMoneySchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(500)
|
||||||
|
.transform((list) =>
|
||||||
|
list
|
||||||
|
.filter((l) => l.amount > 0)
|
||||||
|
.map((l) => ({ categoryId: l.categoryId, amountMinor: l.amount })),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
export type BudgetLimitsForm = z.output<typeof budgetLimitsSchema>;
|
||||||
Reference in New Issue
Block a user