import "server-only"; import { Types } from "mongoose"; import { incomePlan } from "@/features/budgets/plan"; import { budgetFor, expenseAmounts, settingsOf, type BudgetLineView, type BudgetView, type Settings, } from "@/features/budgets/service"; import { loadContext, userNames, type Ctx } from "@/features/transactions/service"; import { tr } from "@/i18n/tr"; import { TransactionModel } from "@/models/transaction"; import type { AccountType } from "@/shared/accounts"; import type { IconKey, Swatch } from "@/shared/appearance"; import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget"; import { istanbulDayStart, todayIstanbul } from "@/shared/dates"; import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from "@/shared/reports"; /** * Raporlar (Bölüm 6.14). Okuma: viewer. "Ay" bütçe ayıdır (hane ayarı); gider kuralları bütçeyle * aynı: gerçekleşmiş işlemler, bölünmüş işlemler kalem bazında, taksit modu ve başkasının * "yalnızca ben" hesabındaki giderler hane ayarına göre (yalnızca tutar). Başkasının gizli * hesabı hesap raporunda hiç görünmez. */ const DAY_MS = 86_400_000; export const ORPHAN = "__none__"; export type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string }; export async function base(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(); return { ctx, s, today, currentMonth: periodOfDate(today, s.startDay) }; } /** Kategori bazında gerçekleşen gider (kendi tutarı); kategorisiz/bilinmeyen tek satırda. */ export async function actualByCategory(b: Base, month: string) { const { from, to } = budgetPeriod(month, b.s.startDay); const amounts = await expenseAmounts(b.ctx, b.s, from, to); const out = new Map(); for (const [id, a] of amounts) { const v = a.fixedMinor + a.variableMinor; if (!v) continue; const known = id && b.ctx.categoryMap.get(id)?.kind === "expense"; const key = known ? id : ORPHAN; out.set(key, (out.get(key) ?? 0) + v); } return out; } export type MonthInfo = { month: string; from: string; to: string; current: boolean; currentMonth: string; }; export const monthInfo = (b: Base, month: string): MonthInfo => ({ month, ...budgetPeriod(month, b.s.startDay), current: month === b.currentMonth, currentMonth: b.currentMonth, }); // --- Kategori dağılımı --- export type CategoryReport = MonthInfo & { previous: string; breakdown: CategoryBreakdown; appearance: Record; includesHidden: boolean; }; export async function getCategoryReport( userId: string, householdId: string, month?: string, ): Promise { const b = await base(userId, householdId); if (!b) return null; const m = month ?? b.currentMonth; const prev = shiftMonth(m, -1); const [current, previous] = await Promise.all([ actualByCategory(b, m), actualByCategory(b, prev), ]); const cats = b.ctx.flatCategories .filter((c) => c.kind === "expense") .map((c) => ({ id: c.id, parentId: c.parentId, name: c.name, order: c.order })); cats.push({ id: ORPHAN, parentId: null, name: tr.reports.uncategorized, order: 1e9 }); const appearance: CategoryReport["appearance"] = { [ORPHAN]: { icon: "tag", color: "slate", path: tr.reports.uncategorized }, }; for (const c of b.ctx.categoryMap.values()) if (c.kind === "expense") appearance[String(c.id)] = { icon: c.icon, color: c.color, path: c.path }; return { ...monthInfo(b, m), previous: prev, breakdown: categoryBreakdown(cats, current, previous), appearance, includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0, }; } // --- Aylık trend --- export type TrendRow = { month: string; incomeMinor: number; expenseMinor: number; netMinor: number; savingsPct: number | null; }; export type TrendReport = { months: TrendRow[]; totals: { incomeMinor: number; expenseMinor: number; netMinor: number; savingsPct: number | null; }; currentMonth: string; }; /** Son `count` bütçe ayı (içinde bulunulan dahil): gerçekleşen gelir, gider, net, tasarruf oranı. */ export async function getTrendReport( userId: string, householdId: string, count = 12, ): Promise { const b = await base(userId, householdId); if (!b) return null; const months = Array.from({ length: count }, (_, i) => shiftMonth(b.currentMonth, i - count + 1)); const rows = await Promise.all( months.map(async (month) => { const [income, expense] = await Promise.all([ incomePlan(b.ctx, b.s, month), actualByCategory(b, month), ]); const incomeMinor = income.actualMinor; const expenseMinor = [...expense.values()].reduce((a, v) => a + v, 0); return { month, incomeMinor, expenseMinor, netMinor: incomeMinor - expenseMinor, savingsPct: savingsRate(incomeMinor, expenseMinor), }; }), ); const incomeMinor = rows.reduce((a, r) => a + r.incomeMinor, 0); const expenseMinor = rows.reduce((a, r) => a + r.expenseMinor, 0); return { months: rows, totals: { incomeMinor, expenseMinor, netMinor: incomeMinor - expenseMinor, savingsPct: savingsRate(incomeMinor, expenseMinor), }, currentMonth: b.currentMonth, }; } // --- Kişi ve hesap bazlı --- export type PersonRow = { userId: string; name: string; expenseMinor: number; count: number; sharePct: number; }; export type AccountRow = { id: string; name: string; type: AccountType; archived: boolean; incomeMinor: number; expenseMinor: number; transferInMinor: number; transferOutMinor: number; netMinor: number; count: number; }; export type PeopleAccountsReport = MonthInfo & { people: PersonRow[]; peopleTotalMinor: number; accounts: AccountRow[]; includesHidden: boolean; }; type TxLean = { type: "income" | "expense" | "transfer"; amountMinor: number; accountId: Types.ObjectId | null; toAccountId: Types.ObjectId | null; createdBy: Types.ObjectId; }; export async function getPeopleAccountsReport( userId: string, householdId: string, month?: string, ): Promise { const b = await base(userId, householdId); if (!b) return null; const m = month ?? b.currentMonth; const info = monthInfo(b, m); const docs = await TransactionModel.find({ householdId: b.ctx.m.householdId, deletedAt: null, status: "posted", date: { $gte: istanbulDayStart(info.from)!, $lt: new Date(istanbulDayStart(info.to)!.getTime() + DAY_MS), }, }) .select({ type: 1, amountMinor: 1, accountId: 1, toAccountId: 1, createdBy: 1 }) .lean(); const hidden = new Set(b.ctx.hiddenAccountIds.map(String)); // Kişi: kaydı giren üye; gizli hesap giderleri hane ayarına göre (yalnızca tutar). const people = new Map(); for (const d of docs) { if (d.type !== "expense") continue; if (d.accountId && hidden.has(String(d.accountId)) && !b.s.includePrivate) continue; const key = String(d.createdBy); const p = people.get(key) ?? { expense: 0, count: 0 }; p.expense += d.amountMinor; p.count++; people.set(key, p); } const names = await userNames([...people.keys()].map((id) => new Types.ObjectId(id))); const peopleTotal = [...people.values()].reduce((a, p) => a + p.expense, 0); // Hesap: başkasının gizli hesabı hiç görünmez. const acc = new Map>(); const bump = ( id: Types.ObjectId | null, k: "incomeMinor" | "expenseMinor" | "transferInMinor" | "transferOutMinor", v: number, ) => { if (!id || hidden.has(String(id))) return; const key = String(id); const row = acc.get(key) ?? { incomeMinor: 0, expenseMinor: 0, transferInMinor: 0, transferOutMinor: 0, count: 0, }; row[k] += v; row.count++; acc.set(key, row); }; for (const d of docs) { if (d.type === "income") bump(d.accountId, "incomeMinor", d.amountMinor); else if (d.type === "expense") bump(d.accountId, "expenseMinor", d.amountMinor); else { bump(d.accountId, "transferOutMinor", d.amountMinor); bump(d.toAccountId, "transferInMinor", d.amountMinor); } } const accounts: AccountRow[] = []; for (const a of b.ctx.accountMap.values()) { if (a.hidden) continue; const row = acc.get(String(a.id)); if (!row && a.archived) continue; const r = row ?? { incomeMinor: 0, expenseMinor: 0, transferInMinor: 0, transferOutMinor: 0, count: 0, }; accounts.push({ id: String(a.id), name: a.name, type: a.type, archived: a.archived, ...r, netMinor: r.incomeMinor + r.transferInMinor - r.expenseMinor - r.transferOutMinor, }); } accounts.sort((x, y) => y.count - x.count || x.name.localeCompare(y.name, "tr")); return { ...info, people: [...people.entries()] .map(([id, p]) => ({ userId: id, name: names.get(id) ?? "—", expenseMinor: p.expense, count: p.count, sharePct: sharePct(p.expense, peopleTotal), })) .sort((x, y) => y.expenseMinor - x.expenseMinor), peopleTotalMinor: peopleTotal, accounts, includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0, }; } // --- Bütçe vs. gerçekleşen --- export type BudgetHistoryRow = { month: string; limitMinor: number | null; actualMinor: number; ratioBp: number | null; }; export type BudgetReport = MonthInfo & { budget: BudgetView; lines: BudgetLineView[]; history: BudgetHistoryRow[]; }; /** Seçilen ayın kategori bazında bütçe/gerçekleşen tablosu ve son 6 ayın toplamları. */ export async function getBudgetReport( userId: string, householdId: string, month?: string, ): Promise { const b = await base(userId, householdId); if (!b) return null; const m = month ?? b.currentMonth; const months = Array.from({ length: 6 }, (_, i) => shiftMonth(m, i - 5)); const budgets = await Promise.all(months.map((x) => budgetFor(b.ctx, b.s, x))); const budget = budgets.at(-1)!; return { ...monthInfo(b, m), budget, lines: budget.lines, history: budgets.map((x) => ({ month: x.month, limitMinor: x.hasBudget ? x.totals.limitMinor : null, actualMinor: x.totals.actualMinor, ratioBp: x.hasBudget ? x.totals.ratioBp : null, })), }; }