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>; 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>) => 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("tahmin: değişken gider tempoyla, sabit gider olduğu gibi (gelecek ayda değişken sayılmaz)", async () => { const F = shiftMonth(todayIstanbul().slice(0, 7), 24); const f = budgetPeriod(F); await expense({ date: f.from, amountMinor: 900_00, category: cat.keyif }); await expense({ date: f.from, amountMinor: 4_000_00, category: cat.kira, recurring: true }); const keyif = (await line("keyif", F))!; expect(keyif.actualMinor).toBe(900_00); // Henüz başlamamış dönem, geçmiş yok: gerçekleşen değişken olduğu gibi, tempo eklenmez. expect(keyif.forecastMinor).toBe(900_00); expect((await line("kira", F))!.forecastMinor).toBe(4_000_00); }); it("içinde bulunulan ayda değişken gider tempoyla büyür, sabit gider büyümez", async () => { const today = todayIstanbul(); const cur = today.slice(0, 7); if (budgetPeriod(cur).to === today) return; // ayın son günü: kalan gün yok await expense({ date: today, amountMinor: 1_000_00, category: cat.keyif }); await expense({ date: today, amountMinor: 2_000_00, category: cat.kira, recurring: true }); const keyif = (await line("keyif", cur))!; expect(keyif.actualMinor).toBe(1_000_00); expect(keyif.forecastMinor).toBeGreaterThan(1_000_00); expect((await line("kira", cur))!.forecastMinor).toBe(2_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); }); });