feat: Faz 10a - aylık bütçe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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ı).
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<string, Types.ObjectId>,
|
||||
userIds: Map<string, string>,
|
||||
): Promise<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof getAuth>["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 = [
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
||||
<BudgetView key={data.month} data={data} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<Suspense fallback={null}>
|
||||
<OverdueBanner userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<BudgetWarningBanner userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<ContactsDueBanner userId={user.id} householdId={household.id} />
|
||||
</Suspense>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.join(".");
|
||||
if (key && !out[key]) out[key] = issue.message;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function run<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<unknown>>,
|
||||
): Promise<ActionResult<unknown>> {
|
||||
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);
|
||||
}
|
||||
@@ -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<BudgetStatus, string> = {
|
||||
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 (
|
||||
<div
|
||||
className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(ratioBp === null ? 0 : ratioBp / 100)}
|
||||
>
|
||||
<div className={cn("h-full rounded-full", BAR[status])} style={{ width: `${pct}%` }} />
|
||||
{marker !== undefined && marker > 0 && marker < 100 ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-0 h-full w-0.5 bg-foreground/60"
|
||||
style={{ left: `${marker}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
status,
|
||||
forecastOver,
|
||||
exceeded,
|
||||
}: {
|
||||
status: BudgetStatus;
|
||||
forecastOver: boolean;
|
||||
/** Limit aşıldı mı (tam %100'de "doldu"). */
|
||||
exceeded: boolean;
|
||||
}) {
|
||||
if (status === "over")
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<AlertTriangle className="size-3" />
|
||||
{exceeded ? t.status.over : t.status.full}
|
||||
</Badge>
|
||||
);
|
||||
if (status === "warning")
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-500/50 text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
<AlertTriangle className="size-3" />
|
||||
{t.status.warning}
|
||||
</Badge>
|
||||
);
|
||||
if (forecastOver)
|
||||
return (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
{t.status.forecastOver}
|
||||
</Badge>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-lg font-semibold tabular-nums", tone)}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null;
|
||||
|
||||
export function BudgetView({ data }: { data: Data }) {
|
||||
const [pending, setPending] = useState<Pending>(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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/butce?ay=${shiftMonth(data.month, -1)}`} aria-label={t.prevMonth}>
|
||||
<ChevronLeft />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="min-w-36 text-center">
|
||||
<p className="font-medium capitalize">{monthLabel(data.month)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.period(d(data.period.from), d(data.period.to))}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/butce?ay=${shiftMonth(data.month, 1)}`} aria-label={t.nextMonth}>
|
||||
<ChevronRight />
|
||||
</Link>
|
||||
</Button>
|
||||
{data.month !== data.currentMonth ? (
|
||||
<Button variant="link" size="sm" asChild>
|
||||
<Link href="/butce">{t.thisMonth}</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{data.canWrite ? (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => setPending({ kind: "edit" })}>
|
||||
<Pencil />
|
||||
{t.edit}
|
||||
</Button>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon-sm" variant="outline" aria-label={tr.recurring.actions.more}>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={!data.hasPrevious || busy}
|
||||
onSelect={() => askOrRun("copy")}
|
||||
>
|
||||
<Copy />
|
||||
{t.copyPrevious}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={busy} onSelect={() => askOrRun("actuals")}>
|
||||
<History />
|
||||
{t.fromActuals}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Card size="sm">
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat
|
||||
label={t.totals.limit}
|
||||
value={data.hasBudget ? formatMoney(tt.limitMinor) : "—"}
|
||||
/>
|
||||
<Stat label={t.totals.actual} value={formatMoney(tt.actualMinor)} tone="text-expense" />
|
||||
<Stat
|
||||
label={tt.remainingMinor < 0 ? t.totals.over : t.totals.remaining}
|
||||
value={data.hasBudget ? formatMoney(Math.abs(tt.remainingMinor)) : "—"}
|
||||
tone={tt.remainingMinor < 0 ? "text-expense" : "text-budget"}
|
||||
/>
|
||||
<Stat
|
||||
label={t.totals.forecast}
|
||||
value={formatMoney(tt.forecastMinor)}
|
||||
tone={tt.forecastOver ? "text-expense" : undefined}
|
||||
/>
|
||||
</div>
|
||||
{data.hasBudget ? (
|
||||
<Bar
|
||||
ratioBp={tt.ratioBp}
|
||||
status={tt.status}
|
||||
marker={monthPct}
|
||||
label={`${t.totals.actual} / ${t.totals.limit}`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap justify-between gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t.totals.monthProgress(data.elapsedDays, data.totalDays, String(monthPct))}
|
||||
{tt.ratioBp !== null ? ` · %${Math.round(tt.ratioBp / 100)}` : ""}
|
||||
</span>
|
||||
{tt.plannedMinor ? <span>{t.totals.planned(formatMoney(tt.plannedMinor))}</span> : null}
|
||||
{data.hasBudget && tt.unbudgetedMinor ? (
|
||||
<span>
|
||||
{t.totals.unbudgeted}: {formatMoney(tt.unbudgetedMinor)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{data.includesHidden ? (
|
||||
<p className="text-xs text-muted-foreground">{t.includesHidden}</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{warnings.length ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm"
|
||||
>
|
||||
<AlertTriangle className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
{t.warnings(warnings.length)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!data.hasBudget ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed p-6 text-center">
|
||||
<p className="font-medium">{t.empty}</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">{t.emptyHint}</p>
|
||||
{data.canWrite ? (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button size="sm" onClick={() => setPending({ kind: "edit" })}>
|
||||
<Pencil />
|
||||
{t.edit}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!data.hasPrevious || busy}
|
||||
onClick={() => generate("copy")}
|
||||
>
|
||||
<Copy />
|
||||
{t.copyPrevious}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => generate("actuals")}
|
||||
>
|
||||
<History />
|
||||
{t.fromActuals}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.lines.length ? (
|
||||
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
||||
{data.lines.map((l) => (
|
||||
<Line key={l.categoryId} line={l} marker={monthPct} />
|
||||
))}
|
||||
</ul>
|
||||
) : data.hasBudget ? (
|
||||
<p className="text-sm text-muted-foreground">{t.noSpending}</p>
|
||||
) : null}
|
||||
|
||||
{pending?.kind === "edit" ? <EditDialog data={data} onClose={close} /> : null}
|
||||
<AlertDialog open={pending?.kind === "replace"} onOpenChange={(o) => !o && close()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.replaceTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t.replaceDescription}
|
||||
{pending?.kind === "replace" && pending.source === "actuals"
|
||||
? ` ${t.fromActualsHint}`
|
||||
: ""}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => pending?.kind === "replace" && generate(pending.source)}
|
||||
>
|
||||
{t.replace}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Line({ line: l, marker }: { line: BudgetLineView; marker: number }) {
|
||||
const hasLimit = l.limitMinor !== null;
|
||||
return (
|
||||
<li
|
||||
className="flex items-start gap-3 p-3"
|
||||
style={{ paddingLeft: `${0.75 + l.depth * 1.25}rem` }}
|
||||
>
|
||||
<IconBadge icon={l.icon} color={l.color} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
|
||||
<p className="flex min-w-0 items-center gap-2 font-medium">
|
||||
<span className="truncate">{l.name}</span>
|
||||
<StatusBadge
|
||||
status={l.status}
|
||||
forecastOver={l.forecastOver}
|
||||
exceeded={(l.remainingMinor ?? 0) < 0}
|
||||
/>
|
||||
</p>
|
||||
<p className="text-sm tabular-nums">
|
||||
{hasLimit
|
||||
? t.line.ofLimit(formatMoney(l.actualMinor), formatMoney(l.limitMinor!))
|
||||
: formatMoney(l.actualMinor)}
|
||||
</p>
|
||||
</div>
|
||||
{hasLimit ? (
|
||||
<Bar
|
||||
ratioBp={l.ratioBp}
|
||||
status={l.status}
|
||||
marker={marker}
|
||||
label={`${l.name}: ${t.line.ofLimit(formatMoney(l.actualMinor), formatMoney(l.limitMinor!))}`}
|
||||
/>
|
||||
) : null}
|
||||
<p className="flex flex-wrap gap-x-3 text-xs text-muted-foreground">
|
||||
{hasLimit ? (
|
||||
<span className={cn(l.remainingMinor! < 0 && "text-expense")}>
|
||||
{l.remainingMinor! < 0
|
||||
? t.line.over(formatMoney(-l.remainingMinor!))
|
||||
: t.line.remaining(formatMoney(l.remainingMinor!))}
|
||||
</span>
|
||||
) : (
|
||||
<span>{l.covered ? t.status.covered : t.status.none}</span>
|
||||
)}
|
||||
{l.plannedMinor ? <span>{t.line.planned(formatMoney(l.plannedMinor))}</span> : null}
|
||||
{l.forecastMinor !== l.actualMinor ? (
|
||||
<span className={cn(l.forecastOver && "text-expense")}>
|
||||
{t.line.forecast(formatMoney(l.forecastMinor))}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function EditDialog({ data, onClose }: { data: Data; onClose: () => void }) {
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
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<string | null>(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 (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={t.editTitle(monthLabel(data.month))}
|
||||
description={t.editHint}
|
||||
footer={
|
||||
<>
|
||||
<span className="mr-auto self-center text-sm font-medium tabular-nums">
|
||||
{t.editTotal(formatMoney(total))}
|
||||
</span>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="budget-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="budget-form" onSubmit={submit} noValidate className="flex flex-col">
|
||||
{data.editor.categories.map((c) => (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex items-center justify-between gap-3 border-b py-1.5 last:border-b-0"
|
||||
style={{ paddingLeft: `${c.depth * 1.25}rem` }}
|
||||
>
|
||||
<span className={cn("min-w-0 truncate text-sm", c.depth === 0 && "font-medium")}>
|
||||
{c.name}
|
||||
</span>
|
||||
<div className="w-36 shrink-0">
|
||||
<MoneyInput
|
||||
id={`limit-${c.id}`}
|
||||
value={values[c.id] ?? ""}
|
||||
onChange={(x) => setValues((s) => ({ ...s, [c.id]: x }))}
|
||||
aria-label={c.name}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
{error ? <p className="pt-2 text-sm text-destructive">{error}</p> : null}
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-wrap items-center gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-3 text-sm"
|
||||
>
|
||||
<AlertTriangle className="size-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
|
||||
<span className="flex-1">{tr.budget.warnings(n)}</span>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href="/butce">{tr.budget.goToBudget}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ReturnType<typeof setupSeededDb>>;
|
||||
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<Record<CatKey, string>>) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<Settings> {
|
||||
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<Map<string, CategoryAmounts>> {
|
||||
const start = istanbulDayStart(from)!;
|
||||
const end = new Date(istanbulDayStart(to)!.getTime() + DAY_MS);
|
||||
const base: Record<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
...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<string, CategoryAmounts>();
|
||||
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<string, number>;
|
||||
};
|
||||
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<BudgetView | null> {
|
||||
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<ActionResult> {
|
||||
const valid = new Set(
|
||||
expenseCategories(ctx)
|
||||
.filter((c) => !c.archived)
|
||||
.map((c) => c.id),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
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<string, number>();
|
||||
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<number> {
|
||||
const b = await getBudget(userId, householdId);
|
||||
if (!b?.hasBudget) return 0;
|
||||
return b.lines.filter((l) => l.status === "warning" || l.status === "over").length;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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<typeof budgetSchema>;
|
||||
|
||||
export const BudgetModel: Model<Budget> =
|
||||
(models.Budget as Model<Budget> | undefined) ?? model<Budget>("Budget", budgetSchema);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>;
|
||||
amounts: Map<string, CategoryAmounts>;
|
||||
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<string, BudgetCategoryInput[]>();
|
||||
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<string, CategoryAmounts>();
|
||||
const sumOf = (c: BudgetCategoryInput, seen: Set<string>): 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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<typeof budgetLimitsSchema>;
|
||||
Reference in New Issue
Block a user