From 3fa1e949e2086478b8a3c9981da3adad69035d8a Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 00:58:51 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(budget):=20ayl=C4=B1k=20b=C3=BCt=C3=A7?= =?UTF-8?q?e=20motoru,=20limitler=20ve=20ay=20sonu=20tahmini?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/migrations/010-budgets.ts | 10 + scripts/migrations/index.ts | 3 +- src/features/budgets/actions.ts | 44 +++ src/features/budgets/service.int.test.ts | 243 ++++++++++++++ src/features/budgets/service.ts | 392 +++++++++++++++++++++++ src/i18n/tr.ts | 71 ++++ src/models/budget.ts | 32 ++ src/shared/budget.test.ts | 139 ++++++++ src/shared/budget.ts | 217 +++++++++++++ src/shared/schemas/budget.ts | 29 ++ 10 files changed, 1179 insertions(+), 1 deletion(-) create mode 100644 scripts/migrations/010-budgets.ts create mode 100644 src/features/budgets/actions.ts create mode 100644 src/features/budgets/service.int.test.ts create mode 100644 src/features/budgets/service.ts create mode 100644 src/models/budget.ts create mode 100644 src/shared/budget.test.ts create mode 100644 src/shared/budget.ts create mode 100644 src/shared/schemas/budget.ts diff --git a/scripts/migrations/010-budgets.ts b/scripts/migrations/010-budgets.ts new file mode 100644 index 0000000..09a85ec --- /dev/null +++ b/scripts/migrations/010-budgets.ts @@ -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(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 2c8f076..8a1b043 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -9,10 +9,11 @@ import { migration as m006 } from "./006-card-statements"; import { migration as m007 } from "./007-contacts"; import { migration as m008 } from "./008-in-kind"; import { migration as m009 } from "./009-assets"; +import { migration as m010 } from "./010-budgets"; import type { Migration } from "./types"; /** 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"; diff --git a/src/features/budgets/actions.ts b/src/features/budgets/actions.ts new file mode 100644 index 0000000..cabf5f9 --- /dev/null +++ b/src/features/budgets/actions.ts @@ -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 = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +async function run( + schema: z.ZodType, + input: unknown, + fn: (userId: string, householdId: string, data: T) => Promise>, +): Promise> { + 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); +} diff --git a/src/features/budgets/service.int.test.ts b/src/features/budgets/service.int.test.ts new file mode 100644 index 0000000..13ecfe4 --- /dev/null +++ b/src/features/budgets/service.int.test.ts @@ -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>; +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>) => + 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); + }); +}); diff --git a/src/features/budgets/service.ts b/src/features/budgets/service.ts new file mode 100644 index 0000000..f15905f --- /dev/null +++ b/src/features/budgets/service.ts @@ -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 { + 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> { + const start = istanbulDayStart(from)!; + const end = new Date(istanbulDayStart(to)!.getTime() + DAY_MS); + const base: Record = { + 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 = { + ...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(); + 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; + }; + 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 { + 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 { + const valid = new Set( + expenseCategories(ctx) + .filter((c) => !c.archived) + .map((c) => c.id), + ); + const seen = new Set(); + 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 { + 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 { + 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 { + 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(); + 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 { + const b = await getBudget(userId, householdId); + if (!b?.hasBudget) return 0; + return b.lines.filter((l) => l.status === "warning" || l.status === "over").length; +} diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 997b2ef..ea50176 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -804,6 +804,77 @@ export const tr = { 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: { title: "Fiyat tablosu", description: diff --git a/src/models/budget.ts b/src/models/budget.ts new file mode 100644 index 0000000..16f6446 --- /dev/null +++ b/src/models/budget.ts @@ -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; + +export const BudgetModel: Model = + (models.Budget as Model | undefined) ?? model("Budget", budgetSchema); diff --git a/src/shared/budget.test.ts b/src/shared/budget.test.ts new file mode 100644 index 0000000..9f17e2d --- /dev/null +++ b/src/shared/budget.test.ts @@ -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); + }); +}); diff --git a/src/shared/budget.ts b/src/shared/budget.ts new file mode 100644 index 0000000..b0bd5ac --- /dev/null +++ b/src/shared/budget.ts @@ -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; + amounts: Map; + 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(); + 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(); + const sumOf = (c: BudgetCategoryInput, seen: Set): 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(); + 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; +} diff --git a/src/shared/schemas/budget.ts b/src/shared/schemas/budget.ts new file mode 100644 index 0000000..4c8bbe2 --- /dev/null +++ b/src/shared/schemas/budget.ts @@ -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; From 62023ef35d6dc8768188ce093364434be96af89f Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 00:58:51 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(ui):=20b=C3=BCt=C3=A7e=20sayfas=C4=B1,?= =?UTF-8?q?=20limit=20d=C3=BCzenleyici=20ve=20dashboard=20uyar=C4=B1s?= =?UTF-8?q?=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/butce/page.tsx | 24 + src/app/(app)/dashboard/page.tsx | 4 + src/components/layout/nav-config.ts | 4 +- .../budgets/components/budget-view.tsx | 480 ++++++++++++++++++ .../budgets/components/warning-banner.tsx | 29 ++ 5 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 src/app/(app)/butce/page.tsx create mode 100644 src/features/budgets/components/budget-view.tsx create mode 100644 src/features/budgets/components/warning-banner.tsx diff --git a/src/app/(app)/butce/page.tsx b/src/app/(app)/butce/page.tsx new file mode 100644 index 0000000..9ebe6f3 --- /dev/null +++ b/src/app/(app)/butce/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { BudgetView } from "@/features/budgets/components/budget-view"; +import { getBudget } from "@/features/budgets/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; +import { budgetMonthSchema } from "@/shared/schemas/budget"; + +export const metadata: Metadata = { title: tr.budget.title }; + +/** /butce?ay=YYYY-MM — aylık bütçe (varsayılan: içinde bulunulan bütçe ayı). */ +export default async function BudgetPage({ searchParams }: PageProps<"/butce">) { + const { user, household } = await requireActiveHousehold("viewer"); + const ay = budgetMonthSchema.safeParse((await searchParams).ay); + const data = await getBudget(user.id, household.id, ay.success ? ay.data : undefined); + if (!data) notFound(); + return ( + <> + + + + ); +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index fb0f8c2..a6b41a0 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -4,6 +4,7 @@ import { Suspense } from "react"; import { PageHeader } from "@/components/page-header"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; +import { BudgetWarningBanner } from "@/features/budgets/components/warning-banner"; import { ContactsDueBanner } from "@/features/contacts/components/due-banner"; import { getNetWorth } from "@/features/net-worth/service"; import { OverdueBanner } from "@/features/recurring/components/overdue-banner"; @@ -79,6 +80,9 @@ export default async function DashboardPage() { + + + diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 36571a8..e5daebc 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -27,7 +27,7 @@ export type NavItem = { /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ -export const CURRENT_PHASE = 9.2; +export const CURRENT_PHASE = 10.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; @@ -39,7 +39,7 @@ export const MAIN_NAV: NavItem[] = [ { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 }, { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 }, { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 }, - { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 }, + { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10.1 }, { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 }, { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 }, { href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 }, diff --git a/src/features/budgets/components/budget-view.tsx b/src/features/budgets/components/budget-view.tsx new file mode 100644 index 0000000..40d7e77 --- /dev/null +++ b/src/features/budgets/components/budget-view.tsx @@ -0,0 +1,480 @@ +"use client"; + +import { + AlertTriangle, + ChevronLeft, + ChevronRight, + Copy, + EllipsisVertical, + History, + Pencil, +} from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; +import { IconBadge } from "@/components/appearance/swatch"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { shiftMonth, type BudgetStatus } from "@/shared/budget"; +import { formatDate, formatMoney } from "@/shared/format"; +import { formatMoneyInput, parseMoney } from "@/shared/money"; +import { budgetFromActualsAction, copyPreviousBudgetAction, saveBudgetAction } from "../actions"; +import type { BudgetLineView, BudgetView as Data } from "../service"; + +const t = tr.budget; +const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`); +const monthFmt = new Intl.DateTimeFormat("tr-TR", { + month: "long", + year: "numeric", + timeZone: "Europe/Istanbul", +}); +const monthLabel = (m: string) => monthFmt.format(new Date(`${m}-15T12:00:00+03:00`)); + +const BAR: Record = { + ok: "bg-budget", + warning: "bg-amber-500", + over: "bg-expense", + none: "bg-muted-foreground/40", +}; + +/** Yüzde çubuğu; `marker` ayın geçen kısmını (yüzde) gösterir. */ +function Bar({ + ratioBp, + status, + marker, + label, +}: { + ratioBp: number | null; + status: BudgetStatus; + marker?: number; + label: string; +}) { + const pct = ratioBp === null ? 0 : Math.min(100, ratioBp / 100); + return ( +
+
+ {marker !== undefined && marker > 0 && marker < 100 ? ( + + ) : null} +
+ ); +} + +function StatusBadge({ + status, + forecastOver, + exceeded, +}: { + status: BudgetStatus; + forecastOver: boolean; + /** Limit aşıldı mı (tam %100'de "doldu"). */ + exceeded: boolean; +}) { + if (status === "over") + return ( + + + {exceeded ? t.status.over : t.status.full} + + ); + if (status === "warning") + return ( + + + {t.status.warning} + + ); + if (forecastOver) + return ( + + {t.status.forecastOver} + + ); + return null; +} + +function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null; + +export function BudgetView({ data }: { data: Data }) { + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(false); + const close = () => setPending(null); + const tt = data.totals; + const monthPct = Math.round((data.elapsedDays * 100) / data.totalDays); + const warnings = data.lines.filter((l) => l.status === "warning" || l.status === "over"); + + async function generate(source: "copy" | "actuals") { + setBusy(true); + const r = + source === "copy" + ? await copyPreviousBudgetAction(data.month) + : await budgetFromActualsAction(data.month); + setBusy(false); + close(); + if (r.ok) toast.success(source === "copy" ? t.toast.copied : t.toast.created); + else toast.error(r.error); + } + const askOrRun = (source: "copy" | "actuals") => + data.hasBudget ? setPending({ kind: "replace", source }) : generate(source); + + return ( +
+
+
+ +
+

{monthLabel(data.month)}

+

+ {t.period(d(data.period.from), d(data.period.to))} +

+
+ + {data.month !== data.currentMonth ? ( + + ) : null} +
+ {data.canWrite ? ( +
+ + + + + + + askOrRun("copy")} + > + + {t.copyPrevious} + + + askOrRun("actuals")}> + + {t.fromActuals} + + + +
+ ) : null} +
+ + + +
+ + + + +
+ {data.hasBudget ? ( + + ) : null} +
+ + {t.totals.monthProgress(data.elapsedDays, data.totalDays, String(monthPct))} + {tt.ratioBp !== null ? ` · %${Math.round(tt.ratioBp / 100)}` : ""} + + {tt.plannedMinor ? {t.totals.planned(formatMoney(tt.plannedMinor))} : null} + {data.hasBudget && tt.unbudgetedMinor ? ( + + {t.totals.unbudgeted}: {formatMoney(tt.unbudgetedMinor)} + + ) : null} +
+ {data.includesHidden ? ( +

{t.includesHidden}

+ ) : null} +
+
+ + {warnings.length ? ( +
+ + {t.warnings(warnings.length)} +
+ ) : null} + + {!data.hasBudget ? ( +
+

{t.empty}

+

{t.emptyHint}

+ {data.canWrite ? ( +
+ + + +
+ ) : null} +
+ ) : null} + + {data.lines.length ? ( +
    + {data.lines.map((l) => ( + + ))} +
+ ) : data.hasBudget ? ( +

{t.noSpending}

+ ) : null} + + {pending?.kind === "edit" ? : null} + !o && close()}> + + + {t.replaceTitle} + + {t.replaceDescription} + {pending?.kind === "replace" && pending.source === "actuals" + ? ` ${t.fromActualsHint}` + : ""} + + + + {tr.common.cancel} + + + + +
+ ); +} + +function Line({ line: l, marker }: { line: BudgetLineView; marker: number }) { + const hasLimit = l.limitMinor !== null; + return ( +
  • + +
    +
    +

    + {l.name} + +

    +

    + {hasLimit + ? t.line.ofLimit(formatMoney(l.actualMinor), formatMoney(l.limitMinor!)) + : formatMoney(l.actualMinor)} +

    +
    + {hasLimit ? ( + + ) : null} +

    + {hasLimit ? ( + + {l.remainingMinor! < 0 + ? t.line.over(formatMoney(-l.remainingMinor!)) + : t.line.remaining(formatMoney(l.remainingMinor!))} + + ) : ( + {l.covered ? t.status.covered : t.status.none} + )} + {l.plannedMinor ? {t.line.planned(formatMoney(l.plannedMinor))} : null} + {l.forecastMinor !== l.actualMinor ? ( + + {t.line.forecast(formatMoney(l.forecastMinor))} + + ) : null} +

    +
    +
  • + ); +} + +function EditDialog({ data, onClose }: { data: Data; onClose: () => void }) { + const [values, setValues] = useState>(() => + Object.fromEntries( + data.editor.categories.map((c) => { + const v = data.editor.limits[c.id]; + return [c.id, v ? formatMoneyInput(v) : ""]; + }), + ), + ); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Toplam: limiti olan ve üstünde limit olmayan kategoriler (alt limitler iki kez sayılmaz). + let total = 0; + const stack: { depth: number; limited: boolean }[] = []; + for (const c of data.editor.categories) { + while (stack.length && stack.at(-1)!.depth >= c.depth) stack.pop(); + const covered = stack.some((s) => s.limited); + const v = parseMoney(values[c.id] ?? "") ?? 0; + if (v > 0 && !covered) total += v; + stack.push({ depth: c.depth, limited: v > 0 }); + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + const r = await saveBudgetAction({ + month: data.month, + limits: Object.entries(values).map(([categoryId, amount]) => ({ categoryId, amount })), + }); + setBusy(false); + if (r.ok) { + toast.success(t.toast.saved); + onClose(); + } else setError(r.error); + } + + return ( + !o && onClose()} + title={t.editTitle(monthLabel(data.month))} + description={t.editHint} + footer={ + <> + + {t.editTotal(formatMoney(total))} + + + + + } + > +
    + {data.editor.categories.map((c) => ( + + ))} + {error ?

    {error}

    : null} +
    +
    + ); +} diff --git a/src/features/budgets/components/warning-banner.tsx b/src/features/budgets/components/warning-banner.tsx new file mode 100644 index 0000000..07da75e --- /dev/null +++ b/src/features/budgets/components/warning-banner.tsx @@ -0,0 +1,29 @@ +import { AlertTriangle } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { tr } from "@/i18n/tr"; +import { countBudgetWarnings } from "../service"; + +/** Dashboard: bu ay %80'i geçilen veya aşılan bütçe kategorileri (uygulama içi uyarı). */ +export async function BudgetWarningBanner({ + userId, + householdId, +}: { + userId: string; + householdId: string; +}) { + const n = await countBudgetWarnings(userId, householdId); + if (!n) return null; + return ( +
    + + {tr.budget.warnings(n)} + +
    + ); +} From a274d297283a03b6643e8d02c980f454a658e7a9 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 6 Nov 2026 00:58:51 +0300 Subject: [PATCH 3/3] =?UTF-8?q?feat(seed):=20=C3=B6rnek=20b=C3=BCt=C3=A7el?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 ++++++++++ scripts/seed/budgets.ts | 50 ++++++++++++++++++++++++++++++++++++ scripts/seed/run.int.test.ts | 2 ++ scripts/seed/run.ts | 6 +++++ 4 files changed, 72 insertions(+) create mode 100644 scripts/seed/budgets.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d39d03d..c08d848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +### Eklendi — Faz 10a: aylık bütçe + +- Bütçe sayfası (`/butce?ay=YYYY-MM`): ay gezintisi, dönem sınırları hane ayarı `budgetMonthStartDay`'e göre (ör. 15'i → 15 Kasım – 14 Aralık). +- Kategori limitleri (`budgets`): her gider kategorisine limit; üst kategori limiti alt kategorileri kapsar, alt kategori limiti üstün içinde alt sınırdır ve toplam bütçede iki kez sayılmaz. Limitsiz alt kategoriler "Üst kategori limitinde" olarak gösterilir; hiçbir limitin kapsamadığı harcama "Bütçesiz harcama" toplamında. +- Gerçekleşen / limit / kalan, yüzde çubuğu (ayın geçen kısmı işaretli), %80'de uyarı, %100'de "Limit doldu", aşımda "Limit aşıldı". +- Ay sonu tahmini: sabit giderler (düzenli işlem, taksit) olduğu gibi + kalan planlı giderler + değişken giderin günlük hızıyla ay sonuna yansıtılması; tahmin limiti aşıyorsa işaret. +- Bölünmüş işlemler kalem bazında; taksitler hane ayarına göre her biri 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 olarak sayılır. +- "Önceki aydan kopyala" ve "Önceki ayın harcamalarından oluştur" (ana kategori bazında, 100 TL'ye yukarı yuvarlanır); mevcut limitler varsa onay istenir. +- Limit düzenleyici: tüm etkin gider kategorileri ağaç sırasıyla, anlık toplam bütçe. +- Dashboard: %80'i geçilen veya aşılan kategori sayısıyla uygulama içi bütçe uyarısı. +- Seed: Çakır Ailesi'nde önceki ve bu ay için harcamalardan oluşturulmuş bütçeler (bu ay Market limiti düşük, Elektrik alt limiti). +- `010` migration'ı: `budgets` index'i (hane × ay benzersiz). +- Testler: dönem sınırları, eşikler, tahmin, üst/alt limit toplama, bütçesiz harcama, sıra (birim); limitler, bölünmüş işlem, planlı, gizli hesap ayarı, taksit modları, başlangıç günü, kopyala/oluştur, yetki (entegrasyon). + ## [0.9.0] - 2026-11-06 Faz 9: birikim ve varlık takibi, fiyat tablosu (elle), kâr/zarar, net varlık (9a ve 9b alt fazları). diff --git a/scripts/seed/budgets.ts b/scripts/seed/budgets.ts new file mode 100644 index 0000000..7c76714 --- /dev/null +++ b/scripts/seed/budgets.ts @@ -0,0 +1,50 @@ +import type { Types } from "mongoose"; +import { budgetFromActuals, saveBudget } from "@/features/budgets/service"; +import { BudgetModel } from "@/models/budget"; +import { CategoryModel } from "@/models/category"; +import { HouseholdModel } from "@/models/household"; +import { periodOfDate, shiftMonth } from "@/shared/budget"; +import { todayIstanbul } from "@/shared/dates"; +import { budgetLimitsSchema } from "@/shared/schemas/budget"; +import { formatMoneyInput } from "@/shared/money"; + +/** + * Örnek bütçeler (Faz 10a): Çakır Ailesi'nde önceki ve bu ay, önceki ayların harcamalarından + * oluşturulur. Bu ay "Market" limiti biraz düşürülür ve "Elektrik" alt limiti eklenir; böylece + * uyarı ve alt limit örnekleri görünür. İdempotent: bütçesi olan aya dokunulmaz. + */ +export async function seedBudgets( + householdIds: Map, + userIds: Map, +): Promise { + const householdId = householdIds.get("Çakır Ailesi")!; + const by = userIds.get("ayse")!; + const hh = String(householdId); + const h = await HouseholdModel.findById(householdId).select({ settings: 1 }).lean(); + const current = periodOfDate(todayIstanbul(), h?.settings?.budgetMonthStartDay ?? 1); + let created = 0; + for (const month of [shiftMonth(current, -1), current]) { + if (await BudgetModel.exists({ householdId, month })) continue; + const r = await budgetFromActuals(by, hh, month); + if (!r.ok) continue; + created++; + } + if (created && (await BudgetModel.exists({ householdId, month: current }))) { + const doc = (await BudgetModel.findOne({ householdId, month: current }).lean())!; + const cats = await CategoryModel.find({ + householdId, + defaultKey: { $in: ["market", "faturalar.elektrik"] }, + }).lean(); + const id = (key: string) => String(cats.find((c) => c.defaultKey === key)?._id ?? ""); + const limits = doc.limits.map((l) => ({ + categoryId: String(l.categoryId), + amount: formatMoneyInput( + String(l.categoryId) === id("market") ? Math.round(l.amountMinor * 0.7) : l.amountMinor, + ), + })); + if (id("faturalar.elektrik")) + limits.push({ categoryId: id("faturalar.elektrik"), amount: "900" }); + await saveBudget(by, hh, budgetLimitsSchema.parse({ month: current, limits })); + } + return created; +} diff --git a/scripts/seed/run.int.test.ts b/scripts/seed/run.int.test.ts index 2c9b0ee..a60c5d7 100644 --- a/scripts/seed/run.int.test.ts +++ b/scripts/seed/run.int.test.ts @@ -33,6 +33,7 @@ describe("seed", () => { expect(summary.created.accounts).toBe(7); expect(summary.created.transactions).toBeGreaterThan(150); expect(summary.created.assets).toBe(4); + expect(summary.created.budgets).toBe(2); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount); }); @@ -51,6 +52,7 @@ describe("seed", () => { contacts: 0, inKind: 0, assets: 0, + budgets: 0, }); expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1); expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length); diff --git a/scripts/seed/run.ts b/scripts/seed/run.ts index 6cff6c8..165cbfa 100644 --- a/scripts/seed/run.ts +++ b/scripts/seed/run.ts @@ -12,6 +12,7 @@ import { storedEmail } from "@/shared/account-email"; import { runMigrations } from "../migrations"; import { seedContacts } from "./contacts"; import { seedAssets } from "./assets"; +import { seedBudgets } from "./budgets"; import { seedInKind } from "./in-kind"; import { seedInstallments } from "./installments"; import { seedRecurring } from "./recurring"; @@ -53,6 +54,7 @@ export type SeedSummary = { contacts: number; inKind: number; assets: number; + budgets: number; }; accounts: SeedAccountRow[]; }; @@ -75,6 +77,7 @@ const DOMAIN_COLLECTIONS = [ "inKindDebts", "assets", "assetMovements", + "budgets", ]; type AuthApi = ReturnType["api"]; @@ -129,6 +132,7 @@ export async function runSeed({ contacts: 0, inKind: 0, assets: 0, + budgets: 0, }, accounts: [], }; @@ -309,6 +313,8 @@ export async function runSeed({ if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`); summary.created.assets = await seedAssets(householdIds, userIdMap); if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`); + summary.created.budgets = await seedBudgets(householdIds, userIdMap); + if (summary.created.budgets) log(`✓ Örnek bütçeler: ${summary.created.budgets}`); // 7) Geliştirme hesapları tablosu const all = [