39aa19d168
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
158 lines
6.2 KiB
TypeScript
158 lines
6.2 KiB
TypeScript
import { Types } from "mongoose";
|
||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||
import { AccountModel } from "@/models/account";
|
||
import { HouseholdModel } from "@/models/household";
|
||
import { TransactionModel } from "@/models/transaction";
|
||
import { budgetPeriod, periodOfDate, shiftMonth } from "@/shared/budget";
|
||
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||
import { incomeItemsSchema } from "@/shared/schemas/budget";
|
||
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||
import { getDailyPlan, getIncomePlan, saveIncomeItems } from "./plan";
|
||
import { getBudget } from "./service";
|
||
|
||
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||
let cakir = "";
|
||
let ayse = "";
|
||
let emre = "";
|
||
let zeynep = "";
|
||
let fatma = "";
|
||
const today = todayIstanbul();
|
||
const month = periodOfDate(today);
|
||
|
||
beforeAll(async () => {
|
||
ids = await setupSeededDb();
|
||
cakir = ids.household("Çakır Ailesi");
|
||
ayse = ids.user("ayse");
|
||
emre = ids.user("emre");
|
||
zeynep = ids.user("zeynep");
|
||
fatma = ids.user("fatma");
|
||
});
|
||
afterAll(teardownDb);
|
||
|
||
async function income(
|
||
date: string,
|
||
amountMinor: number,
|
||
status: "posted" | "planned",
|
||
accountId?: Types.ObjectId,
|
||
) {
|
||
const actor = new Types.ObjectId(ayse);
|
||
const acc =
|
||
accountId ??
|
||
(await AccountModel.findOne({ householdId: cakir, name: "Ziraat Maaş" }).lean())!._id;
|
||
await TransactionModel.create({
|
||
householdId: new Types.ObjectId(cakir),
|
||
type: "income",
|
||
status,
|
||
date: istanbulDayStart(date)!,
|
||
accountId: acc,
|
||
amountMinor,
|
||
description: "Test gelir",
|
||
recurringRuleId: status === "planned" ? new Types.ObjectId() : null,
|
||
occurrenceIndex: status === "planned" ? 0 : null,
|
||
createdBy: actor,
|
||
updatedBy: actor,
|
||
});
|
||
}
|
||
|
||
describe("gelir planı", () => {
|
||
it("gerçekleşen + bekleyen planlı + elle eklenen = beklenen", async () => {
|
||
const p0 = (await getIncomePlan(ayse, cakir, month))!;
|
||
const { to } = budgetPeriod(month);
|
||
await income(to, 3_000_00, "planned");
|
||
expect(
|
||
(
|
||
await saveIncomeItems(
|
||
ayse,
|
||
cakir,
|
||
incomeItemsSchema.parse({ month, items: [{ name: "Prim", amount: "5.000" }] }),
|
||
)
|
||
).ok,
|
||
).toBe(true);
|
||
const p = (await getIncomePlan(ayse, cakir, month))!;
|
||
expect(p.pendingMinor).toBe(p0.pendingMinor + 3_000_00);
|
||
expect(p.manual).toEqual([expect.objectContaining({ name: "Prim", amountMinor: 5_000_00 })]);
|
||
expect(p.expectedMinor).toBe(p.actualMinor + p.pendingMinor + 5_000_00);
|
||
expect(p.entries.some((e) => e.status === "planned" && e.amountMinor === 3_000_00)).toBe(true);
|
||
// Elle eklenenler bütçe limitlerini bozmaz.
|
||
expect((await getBudget(ayse, cakir, month))!.editor.limits).toBeTruthy();
|
||
});
|
||
|
||
it("gizli hesaba gelen gelir hane ayarına göre; izleyici yazamaz; başka hane göremez", async () => {
|
||
const actor = new Types.ObjectId(emre);
|
||
const secret = await AccountModel.create({
|
||
householdId: new Types.ObjectId(cakir),
|
||
name: "Emre Gizli Gelir",
|
||
type: "bank",
|
||
openingBalanceMinor: 0,
|
||
openingDate: istanbulDayStart(shiftMonth(month, -2) + "-01")!,
|
||
isPrivate: true,
|
||
ownerUserId: actor,
|
||
createdBy: actor,
|
||
updatedBy: actor,
|
||
});
|
||
const a0 = (await getIncomePlan(ayse, cakir, month))!.actualMinor;
|
||
await income(today, 1_000_00, "posted", secret._id);
|
||
expect((await getIncomePlan(ayse, cakir, month))!.actualMinor).toBe(a0 + 1_000_00);
|
||
await HouseholdModel.updateOne(
|
||
{ _id: cakir },
|
||
{ $set: { "settings.includePrivateAccountsInTotals": false } },
|
||
);
|
||
expect((await getIncomePlan(ayse, cakir, month))!.actualMinor).toBe(a0);
|
||
await HouseholdModel.updateOne(
|
||
{ _id: cakir },
|
||
{ $set: { "settings.includePrivateAccountsInTotals": true } },
|
||
);
|
||
// Sahibi kendi gizli hesabını her zaman görür.
|
||
expect((await getIncomePlan(emre, cakir, month))!.actualMinor).toBe(a0 + 1_000_00);
|
||
|
||
expect(
|
||
(await saveIncomeItems(zeynep, cakir, incomeItemsSchema.parse({ month, items: [] }))).ok,
|
||
).toBe(false);
|
||
expect(await getIncomePlan(fatma, cakir, month)).toBeNull();
|
||
expect(incomeItemsSchema.safeParse({ month, items: [{ name: "", amount: "1" }] }).success).toBe(
|
||
false,
|
||
);
|
||
expect(
|
||
incomeItemsSchema.safeParse({ month, items: [{ name: "X", amount: "0" }] }).success,
|
||
).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("günlük plan", () => {
|
||
it("plan toplamı bütçeye eşit; bugüne kadarki gerçekleşen bütçe motoruyla tutarlı; elde kalacak", async () => {
|
||
const v = (await getDailyPlan(ayse, cakir))!;
|
||
const b = (await getBudget(ayse, cakir))!;
|
||
expect(v.month).toBe(month);
|
||
expect(v.hasBudget).toBe(true);
|
||
const last = v.plan.days.at(-1)!;
|
||
expect(last.cumPlannedMinor).toBe(Math.max(b.totals.limitMinor, v.plan.fixedMinor));
|
||
expect(v.plan.days).toHaveLength(b.totalDays);
|
||
expect(v.plan.todayIndex).toBe(b.elapsedDays - 1);
|
||
// Bugünden sonraki gerçekleşmiş işlem yok: bugüne kadar harcanan = ayın gerçekleşeni.
|
||
expect(v.plan.actualToDateMinor).toBe(b.totals.actualMinor);
|
||
expect(v.projectedLeftMinor).toBe(v.income.expectedMinor - b.totals.forecastMinor);
|
||
expect(v.plannedLeftMinor).toBe(v.income.expectedMinor - b.totals.limitMinor);
|
||
expect(v.next.month).toBe(shiftMonth(month, 1));
|
||
});
|
||
|
||
it("önceki ay örüntüsü modu: dağılım değişir, toplam korunur; bütçesiz hane", async () => {
|
||
const even = (await getDailyPlan(ayse, cakir))!;
|
||
await HouseholdModel.updateOne(
|
||
{ _id: cakir },
|
||
{ $set: { "settings.dailyPlanMode": "previous_month_pattern" } },
|
||
);
|
||
const pattern = (await getDailyPlan(ayse, cakir))!;
|
||
await HouseholdModel.updateOne({ _id: cakir }, { $set: { "settings.dailyPlanMode": "even" } });
|
||
expect(pattern.mode).toBe("previous_month_pattern");
|
||
expect(pattern.plan.days.at(-1)!.cumPlannedMinor).toBe(even.plan.days.at(-1)!.cumPlannedMinor);
|
||
expect(pattern.plan.days.map((d) => d.plannedMinor)).not.toEqual(
|
||
even.plan.days.map((d) => d.plannedMinor),
|
||
);
|
||
|
||
const annemler = ids.household("Annemler");
|
||
const a = (await getDailyPlan(fatma, annemler))!;
|
||
expect(a.hasBudget).toBe(false);
|
||
expect(await getDailyPlan(fatma, cakir)).toBeNull();
|
||
});
|
||
});
|