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 }[]; }; export 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ü). */ export 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 }; } /** Güne göre beklenen gelir (gerçekleşmiş + planlı gelir işlemleri). */ export async function incomeByDay(ctx: Ctx, s: Settings, from: string, to: string) { const rows = await TransactionModel.aggregate<{ _id: string; sum: number }>([ { $match: baseMatch(ctx, s, "income", from, to) }, { $group: { _id: { $dateToString: { date: "$date", format: "%Y-%m-%d", timezone: "Europe/Istanbul" } }, sum: { $sum: "$amountMinor" }, }, }, ]); return new Map(rows.map((r) => [r._id, r.sum])); } 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; }; /** * Bir bütçe ayının günlük planı: sabit giderler kendi günlerine, değişken bütçe hane ayarına göre * (eşit ya da önceki ayın örüntüsü) dağıtılır. */ export async function monthDailyPlan( ctx: Ctx, s: Settings, month: string, budgetMinor: number, today = todayIstanbul(), ): Promise { const period = budgetPeriod(month, s.startDay); const byDay = await expensesByDay(ctx, s, period.from, period.to); let weights: number[] | undefined; if (s.dailyMode === "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), ); } return dailyPlan({ from: period.from, to: period.to, today, budgetMinor, fixedByDay: byDay.fixed, actualByDay: byDay.actual, weights, }); } /** 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, income, nextIncome, nextBudget] = await Promise.all([ getBudget(userId, householdId, month), incomePlan(ctx, s, month), incomePlan(ctx, s, nextMonth), getBudget(userId, householdId, nextMonth), ]); if (!budget) return null; const plan = await monthDailyPlan(ctx, s, month, budget.totals.limitMinor, today); 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"), }; }