diff --git a/src/features/budgets/actions.ts b/src/features/budgets/actions.ts index cabf5f9..b810762 100644 --- a/src/features/budgets/actions.ts +++ b/src/features/budgets/actions.ts @@ -5,7 +5,8 @@ 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 { budgetLimitsSchema, budgetMonthSchema, incomeItemsSchema } from "@/shared/schemas/budget"; +import { saveIncomeItems } from "./plan"; import { budgetFromActuals, copyPreviousBudget, saveBudget } from "./service"; function fieldErrors(error: z.ZodError) { @@ -42,3 +43,7 @@ export async function copyPreviousBudgetAction(month: unknown) { export async function budgetFromActualsAction(month: unknown) { return run(budgetMonthSchema, month, budgetFromActuals); } + +export async function saveIncomeItemsAction(input: unknown) { + return run(incomeItemsSchema, input, saveIncomeItems); +} diff --git a/src/features/budgets/plan.int.test.ts b/src/features/budgets/plan.int.test.ts new file mode 100644 index 0000000..c506408 --- /dev/null +++ b/src/features/budgets/plan.int.test.ts @@ -0,0 +1,157 @@ +import { Types } from "mongoose"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { AccountModel } from "@/models/account"; +import { HouseholdModel } from "@/models/household"; +import { TransactionModel } from "@/models/transaction"; +import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget"; +import { istanbulDayStart, todayIstanbul } from "@/shared/dates"; +import { incomeItemsSchema } from "@/shared/schemas/budget"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { getDailyPlan, getIncomePlan, saveIncomeItems } from "./plan"; +import { getBudget } from "./service"; + +let ids: Awaited>; +let cakir = ""; +let ayse = ""; +let emre = ""; +let zeynep = ""; +let fatma = ""; +const today = todayIstanbul(); +const month = periodOfDate(today); + +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"); +}); +afterAll(teardownDb); + +async function income( + date: string, + amountMinor: number, + status: "posted" | "planned", + accountId?: Types.ObjectId, +) { + const actor = new Types.ObjectId(ayse); + const acc = + accountId ?? + (await AccountModel.findOne({ householdId: cakir, name: "Ziraat Maaş" }).lean())!._id; + await TransactionModel.create({ + householdId: new Types.ObjectId(cakir), + type: "income", + status, + date: istanbulDayStart(date)!, + accountId: acc, + amountMinor, + description: "Test gelir", + recurringRuleId: status === "planned" ? new Types.ObjectId() : null, + occurrenceIndex: status === "planned" ? 0 : null, + createdBy: actor, + updatedBy: actor, + }); +} + +describe("gelir planı", () => { + it("gerçekleşen + bekleyen planlı + elle eklenen = beklenen", async () => { + const p0 = (await getIncomePlan(ayse, cakir, month))!; + const { to } = budgetPeriod(month); + await income(to, 3_000_00, "planned"); + expect( + ( + await saveIncomeItems( + ayse, + cakir, + incomeItemsSchema.parse({ month, items: [{ name: "Prim", amount: "5.000" }] }), + ) + ).ok, + ).toBe(true); + const p = (await getIncomePlan(ayse, cakir, month))!; + expect(p.pendingMinor).toBe(p0.pendingMinor + 3_000_00); + expect(p.manual).toEqual([expect.objectContaining({ name: "Prim", amountMinor: 5_000_00 })]); + expect(p.expectedMinor).toBe(p.actualMinor + p.pendingMinor + 5_000_00); + expect(p.entries.some((e) => e.status === "planned" && e.amountMinor === 3_000_00)).toBe(true); + // Elle eklenenler bütçe limitlerini bozmaz. + expect((await getBudget(ayse, cakir, month))!.editor.limits).toBeTruthy(); + }); + + it("gizli hesaba gelen gelir hane ayarına göre; izleyici yazamaz; başka hane göremez", async () => { + const actor = new Types.ObjectId(emre); + const secret = await AccountModel.create({ + householdId: new Types.ObjectId(cakir), + name: "Emre Gizli Gelir", + type: "bank", + openingBalanceMinor: 0, + openingDate: istanbulDayStart(shiftMonth(month, -2) + "-01")!, + isPrivate: true, + ownerUserId: actor, + createdBy: actor, + updatedBy: actor, + }); + const a0 = (await getIncomePlan(ayse, cakir, month))!.actualMinor; + await income(today, 1_000_00, "posted", secret._id); + expect((await getIncomePlan(ayse, cakir, month))!.actualMinor).toBe(a0 + 1_000_00); + await HouseholdModel.updateOne( + { _id: cakir }, + { $set: { "settings.includePrivateAccountsInTotals": false } }, + ); + expect((await getIncomePlan(ayse, cakir, month))!.actualMinor).toBe(a0); + await HouseholdModel.updateOne( + { _id: cakir }, + { $set: { "settings.includePrivateAccountsInTotals": true } }, + ); + // Sahibi kendi gizli hesabını her zaman görür. + expect((await getIncomePlan(emre, cakir, month))!.actualMinor).toBe(a0 + 1_000_00); + + expect( + (await saveIncomeItems(zeynep, cakir, incomeItemsSchema.parse({ month, items: [] }))).ok, + ).toBe(false); + expect(await getIncomePlan(fatma, cakir, month)).toBeNull(); + expect(incomeItemsSchema.safeParse({ month, items: [{ name: "", amount: "1" }] }).success).toBe( + false, + ); + expect( + incomeItemsSchema.safeParse({ month, items: [{ name: "X", amount: "0" }] }).success, + ).toBe(false); + }); +}); + +describe("günlük plan", () => { + it("plan toplamı bütçeye eşit; bugüne kadarki gerçekleşen bütçe motoruyla tutarlı; elde kalacak", async () => { + const v = (await getDailyPlan(ayse, cakir))!; + const b = (await getBudget(ayse, cakir))!; + expect(v.month).toBe(month); + expect(v.hasBudget).toBe(true); + const last = v.plan.days.at(-1)!; + expect(last.cumPlannedMinor).toBe(Math.max(b.totals.limitMinor, v.plan.fixedMinor)); + expect(v.plan.days).toHaveLength(b.totalDays); + expect(v.plan.todayIndex).toBe(b.elapsedDays - 1); + // Bugünden sonraki gerçekleşmiş işlem yok: bugüne kadar harcanan = ayın gerçekleşeni. + expect(v.plan.actualToDateMinor).toBe(b.totals.actualMinor); + expect(v.projectedLeftMinor).toBe(v.income.expectedMinor - b.totals.forecastMinor); + expect(v.plannedLeftMinor).toBe(v.income.expectedMinor - b.totals.limitMinor); + expect(v.next.month).toBe(shiftMonth(month, 1)); + }); + + it("önceki ay örüntüsü modu: dağılım değişir, toplam korunur; bütçesiz hane", async () => { + const even = (await getDailyPlan(ayse, cakir))!; + await HouseholdModel.updateOne( + { _id: cakir }, + { $set: { "settings.dailyPlanMode": "previous_month_pattern" } }, + ); + const pattern = (await getDailyPlan(ayse, cakir))!; + await HouseholdModel.updateOne({ _id: cakir }, { $set: { "settings.dailyPlanMode": "even" } }); + expect(pattern.mode).toBe("previous_month_pattern"); + expect(pattern.plan.days.at(-1)!.cumPlannedMinor).toBe(even.plan.days.at(-1)!.cumPlannedMinor); + expect(pattern.plan.days.map((d) => d.plannedMinor)).not.toEqual( + even.plan.days.map((d) => d.plannedMinor), + ); + + const annemler = ids.household("Annemler"); + const a = (await getDailyPlan(fatma, annemler))!; + expect(a.hasBudget).toBe(false); + expect(await getDailyPlan(fatma, cakir)).toBeNull(); + }); +}); diff --git a/src/features/budgets/plan.ts b/src/features/budgets/plan.ts new file mode 100644 index 0000000..c17e4b6 --- /dev/null +++ b/src/features/budgets/plan.ts @@ -0,0 +1,261 @@ +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 { TransactionModel } from "@/models/transaction"; +import { budgetPeriod, daysBetween, periodOfDate, shiftMonth } from "@/shared/budget"; +import { dailyPlan, patternWeights, type DailyPlan } from "@/shared/daily-plan"; +import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates"; +import { addDays } from "@/shared/recurrence"; +import type { IncomeItemsForm } from "@/shared/schemas/budget"; +import { getBudget, settingsOf, type Settings } from "./service"; + +/** + * Gelir planı ve günlük plan (Bölüm 6.9). + * + * Gelir planı: dönemdeki gerçekleşmiş gelirler + henüz gerçekleşmemiş planlı gelirler (düzenli + * işlem tekrarları) + elle eklenen beklenen gelirler. Elle eklenen kalem gelir işlemi olarak + * girildiğinde kullanıcı kalemi kaldırır (aksi halde iki kez sayılır). + */ + +const DAY_MS = 86_400_000; + +function baseMatch(ctx: Ctx, s: Settings, type: "income" | "expense", from: string, to: string) { + const m: Record = { + householdId: ctx.m.householdId, + type, + deletedAt: null, + status: { $in: ["posted", "planned"] }, + date: { + $gte: istanbulDayStart(from)!, + $lt: new Date(istanbulDayStart(to)!.getTime() + DAY_MS), + }, + }; + if (!s.includePrivate && ctx.hiddenAccountIds.length) + m.accountId = { $nin: ctx.hiddenAccountIds }; + return m; +} + +export type IncomeEntry = { + id: string; + date: string; + label: string; + amountMinor: number; + status: "posted" | "planned"; +}; + +export type IncomePlan = { + /** Gerçekleşen gelir. */ + actualMinor: number; + /** Henüz gerçekleşmemiş planlı gelir. */ + pendingMinor: number; + manualMinor: number; + /** Beklenen toplam = gerçekleşen + planlı + elle. */ + expectedMinor: number; + /** Planlı/düzenli gelir kalemleri (gerçekleşmiş düzenli gelirler dahil). */ + entries: IncomeEntry[]; + manual: { id: string; name: string; amountMinor: number }[]; +}; + +async function incomePlan(ctx: Ctx, s: Settings, month: string): Promise { + const { from, to } = budgetPeriod(month, s.startDay); + const [docs, budget] = await Promise.all([ + TransactionModel.find(baseMatch(ctx, s, "income", from, to)) + .select({ + date: 1, + status: 1, + amountMinor: 1, + description: 1, + payeeName: 1, + categoryId: 1, + recurringRuleId: 1, + }) + .sort({ date: 1 }) + .lean(), + BudgetModel.findOne({ householdId: ctx.m.householdId, month }).lean(), + ]); + let actual = 0; + let pending = 0; + const entries: IncomeEntry[] = []; + for (const d of docs) { + if (d.status === "posted") actual += d.amountMinor; + else pending += d.amountMinor; + if (d.status === "planned" || d.recurringRuleId) { + const cat = d.categoryId ? ctx.categoryMap.get(String(d.categoryId)) : undefined; + entries.push({ + id: String(d._id), + date: toIstanbulYmd(d.date), + label: d.payeeName || d.description || cat?.name || tr.transactions.types.income, + amountMinor: d.amountMinor, + status: d.status === "posted" ? "posted" : "planned", + }); + } + } + const manual = (budget?.incomeItems ?? []).map((i) => ({ + id: String(i._id), + name: i.name, + amountMinor: i.amountMinor, + })); + const manualMinor = manual.reduce((a, i) => a + i.amountMinor, 0); + return { + actualMinor: actual, + pendingMinor: pending, + manualMinor, + expectedMinor: actual + pending + manualMinor, + entries, + manual, + }; +} + +export async function getIncomePlan( + userId: string, + householdId: string, + month: string, +): Promise { + const ctx = await loadContext(userId, householdId, "viewer"); + if (!ctx) return null; + return incomePlan(ctx, await settingsOf(ctx.m.householdId), month); +} + +export async function saveIncomeItems( + actorId: string, + householdId: string, + input: IncomeItemsForm, +): Promise { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const actor = new Types.ObjectId(actorId); + await BudgetModel.updateOne( + { householdId: ctx.m.householdId, month: input.month }, + { + $set: { + incomeItems: input.items.map((i) => ({ name: i.name, amountMinor: i.amount })), + updatedBy: actor, + }, + $setOnInsert: { createdBy: actor, limits: [] }, + }, + { upsert: true }, + ); + return ok(undefined); +} + +// --- Günlük plan --- + +/** Güne göre gider: sabit (düzenli/taksit; gerçekleşmiş + planlı) ve gerçekleşen (tümü). */ +async function expensesByDay(ctx: Ctx, s: Settings, from: string, to: string) { + const pipeline: PipelineStage[] = [ + { $match: baseMatch(ctx, s, "expense", from, to) }, + { + $group: { + _id: { + d: { $dateToString: { date: "$date", format: "%Y-%m-%d", timezone: "Europe/Istanbul" } }, + s: "$status", + f: { + $or: [ + { $ne: [{ $ifNull: ["$recurringRuleId", null] }, null] }, + { $ne: [{ $ifNull: ["$installment.planId", null] }, null] }, + ], + }, + }, + sum: { $sum: "$amountMinor" }, + }, + }, + ]; + const rows = await TransactionModel.aggregate<{ + _id: { d: string; s: string; f: boolean }; + sum: number; + }>(pipeline); + const fixed = new Map(); + const actual = new Map(); + const variable = new Map(); + const add = (m: Map, k: string, v: number) => m.set(k, (m.get(k) ?? 0) + v); + for (const r of rows) { + if (r._id.f) add(fixed, r._id.d, r.sum); + if (r._id.s === "posted") { + add(actual, r._id.d, r.sum); + if (!r._id.f) add(variable, r._id.d, r.sum); + } + } + return { fixed, actual, variable }; +} + +export type DailyPlanView = { + month: string; + period: { from: string; to: string }; + hasBudget: boolean; + plan: DailyPlan; + mode: "even" | "previous_month_pattern"; + income: IncomePlan; + /** Bütçe ayı sonu tahmini gider (bütçe motorundan). */ + forecastExpenseMinor: number; + /** Bu tempoda ay sonunda elde kalacak: beklenen gelir − tahmini gider. */ + projectedLeftMinor: number; + /** Plana göre elde kalacak: beklenen gelir − bütçe. */ + plannedLeftMinor: number; + next: { month: string; expectedIncomeMinor: number; budgetMinor: number | null }; + canWrite: boolean; +}; + +/** Dashboard günlük planı: içinde bulunulan bütçe ayı. */ +export async function getDailyPlan( + userId: string, + householdId: string, +): Promise { + const ctx = await loadContext(userId, householdId, "viewer"); + if (!ctx) return null; + const s = await settingsOf(ctx.m.householdId); + const today = todayIstanbul(); + const month = periodOfDate(today, s.startDay); + const period = budgetPeriod(month, s.startDay); + const nextMonth = shiftMonth(month, 1); + const mode = s.dailyMode; + const [budget, byDay, income, nextIncome, nextBudget] = await Promise.all([ + getBudget(userId, householdId, month), + expensesByDay(ctx, s, period.from, period.to), + incomePlan(ctx, s, month), + incomePlan(ctx, s, nextMonth), + getBudget(userId, householdId, nextMonth), + ]); + if (!budget) return null; + + let weights: number[] | undefined; + if (mode === "previous_month_pattern") { + const prev = budgetPeriod(shiftMonth(month, -1), s.startDay); + const prevDays = await expensesByDay(ctx, s, prev.from, prev.to); + const len = daysBetween(prev.from, prev.to); + weights = patternWeights( + Array.from({ length: len }, (_, i) => prevDays.variable.get(addDays(prev.from, i)) ?? 0), + daysBetween(period.from, period.to), + ); + } + const plan = dailyPlan({ + from: period.from, + to: period.to, + today, + budgetMinor: budget.totals.limitMinor, + fixedByDay: byDay.fixed, + actualByDay: byDay.actual, + weights, + }); + const forecastExpense = budget.totals.forecastMinor; + return { + month, + period, + hasBudget: budget.hasBudget, + plan, + mode, + income, + forecastExpenseMinor: forecastExpense, + projectedLeftMinor: income.expectedMinor - forecastExpense, + plannedLeftMinor: income.expectedMinor - budget.totals.limitMinor, + next: { + month: nextMonth, + expectedIncomeMinor: nextIncome.expectedMinor, + budgetMinor: nextBudget?.hasBudget ? nextBudget.totals.limitMinor : null, + }, + canWrite: hasHouseholdRole(ctx.m.role, "member"), + }; +} diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index ea50176..b5465dd 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -860,6 +860,60 @@ export const tr = { "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", + income: { + title: "Gelir planı", + description: + "Gerçekleşen gelirler, planlı (düzenli) gelirler ve elle eklenen beklenen gelirler.", + actual: "Gerçekleşen", + pending: "Bekleyen planlı", + manual: "Elle eklenen", + expected: "Beklenen toplam", + plannedLeft: "Plana göre elde kalacak", + projectedLeft: "Bu tempoda elde kalacak", + plannedLeftHint: "Beklenen gelir − bütçe", + projectedLeftHint: "Beklenen gelir − ay sonu tahmini gider", + received: "Alındı", + planned: "Planlı", + empty: "Bu ay için planlı gelir yok.", + edit: "Beklenen gelir ekle", + editTitle: (month: string) => `Elle eklenen gelirler · ${month}`, + editHint: + "Planlı işlemi olmayan beklenen gelirler (ör. prim). Gelir gerçekleşip işlem olarak girildiğinde kalemi buradan kaldırın; aksi halde iki kez sayılır.", + itemName: "Açıklama", + itemAmount: "Tutar", + addItem: "Kalem ekle", + removeItem: "Kalemi kaldır", + saved: "Gelir planı kaydedildi.", + }, + daily: { + title: "Günlük plan", + headline: (monthPct: number, spentPct: number) => + `Bugün ayın %${monthPct}'si geçti, bütçenin %${spentPct}'i harcandı.`, + today: "Bugün için", + todayHint: "Bugünün planı + önceki günlerden kalan (veya aşan) tutar", + plannedToDate: "Bugüne kadar planlanan", + actualToDate: "Bugüne kadar harcanan", + projectedLeft: "Bu tempoda ay sonunda elde kalacak", + chartLabel: "Kümülatif planlanan ve gerçekleşen gider", + cumPlanned: "Planlanan (kümülatif)", + cumActual: "Gerçekleşen (kümülatif)", + income: "Beklenen gelir", + modes: { + even: "Değişken bütçe günlere eşit dağıtıldı.", + previous_month_pattern: "Değişken bütçe önceki ayın günlük örüntüsüne göre dağıtıldı.", + }, + fixedNote: (amount: string) => `Tarihe bağlı giderler kendi günlerinde: ${amount}.`, + table: "Günleri tablo olarak göster", + day: "Gün", + planned: "Plan", + actual: "Gerçekleşen", + noBudget: "Günlük plan için bu ayın bütçesini oluşturun.", + next: (month: string) => `Sonraki ay (${month})`, + nextIncome: "Beklenen gelir", + nextBudget: "Bütçe", + nextDiff: "Fark", + nextNoBudget: "Bütçe yok", + }, toast: { saved: "Bütçe kaydedildi.", copied: "Önceki ayın limitleri kopyalandı.", @@ -1093,7 +1147,7 @@ export const tr = { dashboard: { title: "Ana sayfa", welcome: (name: string) => `Hoş geldin, ${name}`, - welcomeDescription: "Bu ayın özetini burada göreceksin. Veriler sonraki fazlarda bağlanacak.", + welcomeDescription: "Bu ayın özeti, günlük plan ve uyarılar.", cards: { monthIncome: "Bu ay gelir", monthExpense: "Bu ay gider", diff --git a/src/models/budget.ts b/src/models/budget.ts index 16f6446..0d49082 100644 --- a/src/models/budget.ts +++ b/src/models/budget.ts @@ -12,11 +12,21 @@ const limitSchema = new Schema( { _id: false }, ); +/** Gelir planına elle eklenen beklenen gelir (planlı işlemi olmayan, ör. prim). */ +const incomeItemSchema = new Schema( + { + name: { type: String, required: true, trim: true, maxlength: 80 }, + amountMinor: { type: Number, required: true, min: 1 }, + }, + { _id: true }, +); + 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: [] }, + incomeItems: { type: [incomeItemSchema], default: [] }, seedRef: { type: String, default: null }, createdBy: { type: Schema.Types.ObjectId, required: true }, updatedBy: { type: Schema.Types.ObjectId, required: true }, diff --git a/src/shared/daily-plan.test.ts b/src/shared/daily-plan.test.ts new file mode 100644 index 0000000..58e6741 --- /dev/null +++ b/src/shared/daily-plan.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { dailyPlan, distribute, patternWeights } from "./daily-plan"; + +describe("dağıtım", () => { + it("toplam korunur, kalan kuruşlar en büyük kesirlere", () => { + expect(distribute(100, [1, 1, 1])).toEqual([34, 33, 33]); + expect(distribute(10, [1, 0, 3])).toEqual([3, 0, 7]); + expect(distribute(5, [0, 0])).toEqual([3, 2]); + expect(distribute(0, [1, 2])).toEqual([0, 0]); + expect(distribute(7, [])).toEqual([]); + }); + + it("önceki ayın örüntüsü gün sayısına uyarlanır", () => { + expect(patternWeights([5, 0, 2], 4)).toEqual([5, 0, 2, 2]); + expect(patternWeights([5, 0, 2, 1], 2)).toEqual([5, 0]); + expect(patternWeights([], 2)).toEqual([1, 1]); + }); +}); + +describe("günlük plan", () => { + // Aralık: 31 gün. Bütçe 31.000 + kira 10.000 (1'inde) + fatura 3.100 (15'inde). + const base = { + from: "2026-12-01", + to: "2026-12-31", + budgetMinor: 44_100_00, + fixedByDay: new Map([ + ["2026-12-01", 10_000_00], + ["2026-12-15", 3_100_00], + ]), + }; + + it("sabit giderler kendi gününe, değişken bütçe eşit; kümülatif ve yüzde", () => { + const p = dailyPlan({ + ...base, + today: "2026-12-13", + actualByDay: new Map([ + ["2026-12-01", 10_500_00], + ["2026-12-05", 9_000_00], + ["2026-12-13", 2_000_00], + ]), + }); + expect(p.variableMinor).toBe(31_000_00); + expect(p.days[0]!.plannedMinor).toBe(11_000_00); + expect(p.days[1]!.plannedMinor).toBe(1_000_00); + expect(p.days[14]!.plannedMinor).toBe(4_100_00); + expect(p.days.at(-1)!.cumPlannedMinor).toBe(44_100_00); + expect(p.todayIndex).toBe(12); + // 13/31 gün → %42; 21.500 / 44.100 → %49 + expect(p.monthPct).toBe(42); + expect(p.spentPct).toBe(49); + expect(p.plannedToDateMinor).toBe(23_000_00); + expect(p.actualToDateMinor).toBe(21_500_00); + // Bugün: 1.000 + (12 günlük plan 22.000 − harcanan 19.500 = 2.500) + expect(p.todayAllowanceMinor).toBe(3_500_00); + }); + + it("sabit giderler bütçeyi aşarsa değişken 0; dönem dışı bugün; ağırlıklı dağıtım", () => { + const over = dailyPlan({ + ...base, + budgetMinor: 5_000_00, + today: "2027-01-05", + actualByDay: new Map(), + }); + expect(over.variableMinor).toBe(0); + expect(over.todayIndex).toBe(-1); + expect(over.monthPct).toBe(100); + const weights = Array.from({ length: 31 }, (_, i) => (i % 7 === 5 ? 3 : 1)); + const w = dailyPlan({ ...base, today: "2026-11-20", actualByDay: new Map(), weights }); + expect(w.monthPct).toBe(0); + expect(w.days[5]!.plannedMinor).toBeGreaterThan(w.days[6]!.plannedMinor); + expect(w.days.at(-1)!.cumPlannedMinor).toBe(44_100_00); + }); +}); diff --git a/src/shared/daily-plan.ts b/src/shared/daily-plan.ts new file mode 100644 index 0000000..5d122f3 --- /dev/null +++ b/src/shared/daily-plan.ts @@ -0,0 +1,119 @@ +import { daysBetween } from "./budget"; +import { addDays } from "./recurrence"; + +/** + * Günlük plan (Bölüm 6.9). Ayın bütçesi günlere yayılır: belirli tarihe bağlı giderler (düzenli + * işlemler, taksitler) kendi günlerine; kalan değişken bütçe günlere eşit (varsayılan) veya önceki + * ayın günlük harcama örüntüsüne göre (hane ayarı) dağıtılır. Tutarlar kuruş. + */ + +/** `total`ı ağırlıklara göre tamsayı paylara böler; toplam tam olarak `total` olur. */ +export function distribute(total: number, weights: number[]): number[] { + const n = weights.length; + if (n === 0) return []; + const sum = weights.reduce((a, w) => a + Math.max(0, w), 0); + const w = sum > 0 ? weights.map((x) => Math.max(0, x)) : weights.map(() => 1); + const wsum = sum > 0 ? sum : n; + const exact = w.map((x) => (total * x) / wsum); + const out = exact.map(Math.floor); + let rest = total - out.reduce((a, b) => a + b, 0); + // En büyük kalanlara birer kuruş (eşitlikte erken gün önce). + const order = exact + .map((x, i) => ({ i, frac: x - Math.floor(x) })) + .sort((a, b) => b.frac - a.frac || a.i - b.i); + for (let k = 0; rest > 0 && k < order.length; k++, rest--) out[order[k]!.i]! += 1; + return out; +} + +/** Önceki dönemin günlük değişken harcamasını bu dönemin gün sayısına uyarlar (gün sırasıyla). */ +export function patternWeights(previous: number[], totalDays: number): number[] { + if (!previous.length) return Array.from({ length: totalDays }, () => 1); + return Array.from({ length: totalDays }, (_, i) => previous[Math.min(i, previous.length - 1)]!); +} + +export type PlanDay = { + date: string; + /** O günün planı: sabit giderler + değişken pay. */ + plannedMinor: number; + fixedMinor: number; + actualMinor: number; + cumPlannedMinor: number; + cumActualMinor: number; +}; + +export type DailyPlan = { + days: PlanDay[]; + budgetMinor: number; + fixedMinor: number; + variableMinor: number; + /** Bugünün sırası (0'dan); dönem dışındaysa -1. */ + todayIndex: number; + /** Ayın geçen kısmı ve bütçenin harcanan kısmı (yüzde, tamsayı). */ + monthPct: number; + spentPct: number | null; + /** Bugüne kadar planlanan ve gerçekleşen (kümülatif). */ + plannedToDateMinor: number; + actualToDateMinor: number; + /** Bugün için kalan: bugünün planı + önceki günlerden kalan/aşan fark. */ + todayAllowanceMinor: number; +}; + +export function dailyPlan(input: { + from: string; + to: string; + today: string; + budgetMinor: number; + /** Tarihe bağlı (sabit) giderler, güne göre. */ + fixedByDay: Map; + /** Gerçekleşen tüm giderler, güne göre. */ + actualByDay: Map; + /** Değişken bütçe ağırlıkları (gün sayısı kadar); verilmezse eşit. */ + weights?: number[]; +}): DailyPlan { + const total = daysBetween(input.from, input.to); + const dates = Array.from({ length: total }, (_, i) => addDays(input.from, i)); + let fixed = 0; + for (const d of dates) fixed += input.fixedByDay.get(d) ?? 0; + const variable = Math.max(0, input.budgetMinor - fixed); + const shares = distribute(variable, input.weights ?? dates.map(() => 1)); + let cumP = 0; + let cumA = 0; + const days = dates.map((date, i): PlanDay => { + const f = input.fixedByDay.get(date) ?? 0; + const planned = f + shares[i]!; + const actual = input.actualByDay.get(date) ?? 0; + cumP += planned; + cumA += actual; + return { + date, + plannedMinor: planned, + fixedMinor: f, + actualMinor: actual, + cumPlannedMinor: cumP, + cumActualMinor: cumA, + }; + }); + const todayIndex = + input.today < input.from || input.today > input.to + ? -1 + : daysBetween(input.from, input.today) - 1; + const passed = input.today < input.from ? 0 : input.today > input.to ? total : todayIndex + 1; + const upTo = passed > 0 ? days[passed - 1]! : null; + const before = todayIndex > 0 ? days[todayIndex - 1]! : null; + const todayDay = todayIndex >= 0 ? days[todayIndex]! : null; + const actualToDate = upTo?.cumActualMinor ?? 0; + return { + days, + budgetMinor: input.budgetMinor, + fixedMinor: fixed, + variableMinor: variable, + todayIndex, + monthPct: Math.round((passed * 100) / total), + spentPct: input.budgetMinor > 0 ? Math.round((actualToDate * 100) / input.budgetMinor) : null, + plannedToDateMinor: upTo?.cumPlannedMinor ?? 0, + actualToDateMinor: actualToDate, + todayAllowanceMinor: todayDay + ? todayDay.plannedMinor + (before ? before.cumPlannedMinor - before.cumActualMinor : 0) + : 0, + }; +} diff --git a/src/shared/schemas/budget.ts b/src/shared/schemas/budget.ts index 4c8bbe2..b95c709 100644 --- a/src/shared/schemas/budget.ts +++ b/src/shared/schemas/budget.ts @@ -27,3 +27,20 @@ export const budgetLimitsSchema = z.object({ ), }); export type BudgetLimitsForm = z.output; + +/** Gelir planı: elle eklenen beklenen gelirler (liste tümüyle değiştirilir). */ +export const incomeItemsSchema = z.object({ + month: budgetMonthSchema, + items: z + .array( + z.object({ + name: z.string().trim().min(1, tr.common.required).max(80, tr.validation.nameMax), + amount: nonNegativeMoneySchema.refine( + (n) => n > 0, + tr.transactions.validation.amountPositive, + ), + }), + ) + .max(30), +}); +export type IncomeItemsForm = z.output;