feat(budget): gelir planı ve günlük plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,8 @@ import { z } from "zod";
|
|||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { fail, type ActionResult } from "@/lib/action-result";
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
import { getHouseholdActionContext } from "@/lib/dal";
|
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";
|
import { budgetFromActuals, copyPreviousBudget, saveBudget } from "./service";
|
||||||
|
|
||||||
function fieldErrors(error: z.ZodError) {
|
function fieldErrors(error: z.ZodError) {
|
||||||
@@ -42,3 +43,7 @@ export async function copyPreviousBudgetAction(month: unknown) {
|
|||||||
export async function budgetFromActualsAction(month: unknown) {
|
export async function budgetFromActualsAction(month: unknown) {
|
||||||
return run(budgetMonthSchema, month, budgetFromActuals);
|
return run(budgetMonthSchema, month, budgetFromActuals);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveIncomeItemsAction(input: unknown) {
|
||||||
|
return run(incomeItemsSchema, input, saveIncomeItems);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<ReturnType<typeof setupSeededDb>>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> = {
|
||||||
|
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<IncomePlan> {
|
||||||
|
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<IncomePlan | null> {
|
||||||
|
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<ActionResult> {
|
||||||
|
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<string, number>();
|
||||||
|
const actual = new Map<string, number>();
|
||||||
|
const variable = new Map<string, number>();
|
||||||
|
const add = (m: Map<string, number>, 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<DailyPlanView | null> {
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
+55
-1
@@ -860,6 +860,60 @@ export const tr = {
|
|||||||
"Hane ayarı gereği diğer üyelerin gizli hesaplarından yapılan harcamalar dahil.",
|
"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ı.`,
|
warnings: (n: number) => `${n} kategoride bütçenin %80'i geçildi veya limit aşıldı.`,
|
||||||
goToBudget: "Bütçeye git",
|
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: {
|
toast: {
|
||||||
saved: "Bütçe kaydedildi.",
|
saved: "Bütçe kaydedildi.",
|
||||||
copied: "Önceki ayın limitleri kopyalandı.",
|
copied: "Önceki ayın limitleri kopyalandı.",
|
||||||
@@ -1093,7 +1147,7 @@ export const tr = {
|
|||||||
dashboard: {
|
dashboard: {
|
||||||
title: "Ana sayfa",
|
title: "Ana sayfa",
|
||||||
welcome: (name: string) => `Hoş geldin, ${name}`,
|
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: {
|
cards: {
|
||||||
monthIncome: "Bu ay gelir",
|
monthIncome: "Bu ay gelir",
|
||||||
monthExpense: "Bu ay gider",
|
monthExpense: "Bu ay gider",
|
||||||
|
|||||||
@@ -12,11 +12,21 @@ const limitSchema = new Schema(
|
|||||||
{ _id: false },
|
{ _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(
|
const budgetSchema = new Schema(
|
||||||
{
|
{
|
||||||
householdId: { type: Schema.Types.ObjectId, required: true },
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
month: { type: String, required: true, match: /^\d{4}-(0[1-9]|1[0-2])$/ },
|
month: { type: String, required: true, match: /^\d{4}-(0[1-9]|1[0-2])$/ },
|
||||||
limits: { type: [limitSchema], default: [] },
|
limits: { type: [limitSchema], default: [] },
|
||||||
|
incomeItems: { type: [incomeItemSchema], default: [] },
|
||||||
seedRef: { type: String, default: null },
|
seedRef: { type: String, default: null },
|
||||||
createdBy: { type: Schema.Types.ObjectId, required: true },
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, number>;
|
||||||
|
/** Gerçekleşen tüm giderler, güne göre. */
|
||||||
|
actualByDay: Map<string, number>;
|
||||||
|
/** 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,3 +27,20 @@ export const budgetLimitsSchema = z.object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
export type BudgetLimitsForm = z.output<typeof budgetLimitsSchema>;
|
export type BudgetLimitsForm = z.output<typeof budgetLimitsSchema>;
|
||||||
|
|
||||||
|
/** 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<typeof incomeItemsSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user