import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { listAccounts } from "@/features/accounts/service"; import { AccountModel } from "@/models/account"; import { AuditLogModel } from "@/models/audit-log"; import { CategoryModel } from "@/models/category"; import { ReconciliationModel } from "@/models/reconciliation"; import { TransactionModel } from "@/models/transaction"; import { todayIstanbul } from "@/shared/dates"; import { reconcileSchema } from "@/shared/schemas/reconciliation"; import type { TransactionForm } from "@/shared/schemas/transaction"; import { shiftMonth } from "@/shared/statement"; import { setupSeededDb, teardownDb } from "@/test/seeded-db"; import { createTransaction, getTransaction, setTransactionDeleted, updateTransaction, } from "./service"; import { currentMonth, getAccountStatement, previewReconciliation, reconcileAccount, } from "./statement"; let ids: Awaited>; let cakir = ""; let ayse = ""; let emre = ""; let zeynep = ""; let fatma = ""; 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); const acc = (name: string) => AccountModel.findOne({ householdId: cakir, name }) .lean() .then((a) => String(a!._id)); const cat = (key: string) => CategoryModel.findOne({ householdId: cakir, defaultKey: key }) .lean() .then((c) => String(c!._id)); const input = (statementDate: string, statementBalance: string, acceptAdjustment = false) => reconcileSchema.parse({ statementDate, statementBalance, acceptAdjustment }); const tl = (minor: number) => (minor / 100).toFixed(2).replace(".", ","); async function balanceOf(userId: string, accountId: string) { const list = await listAccounts(userId, cakir); return list!.accounts.find((a) => a.id === accountId)!.balanceMinor!; } describe("hesap ekstresi", () => { it("yürüyen bakiye tutarlı; ay sonu = sonraki ay başı; güncel bakiye hesap listesiyle aynı", async () => { const ziraat = await acc("Ziraat Maaş"); const month = shiftMonth(currentMonth(), -1); const s = (await getAccountStatement(ayse, cakir, ziraat, month))!; expect(s.rows.length).toBeGreaterThan(0); // Yeniden eskiye: ilk satırın bakiyesi ay sonu bakiyesi. expect(s.rows[0]!.balanceMinor).toBe(s.closingMinor); expect(s.closingMinor).toBe(s.openingMinor + s.inflowMinor - s.outflowMinor); const asc = [...s.rows].reverse(); let running = s.openingMinor; for (const r of asc) { running += r.deltaMinor; expect(r.balanceMinor).toBe(running); } const next = (await getAccountStatement(ayse, cakir, ziraat, currentMonth()))!; expect(next.openingMinor).toBe(s.closingMinor); expect(next.nextMonth).toBeNull(); expect(s.currentBalanceMinor).toBe(await balanceOf(ayse, ziraat)); }); it("gizli hesabın ekstresi yalnızca sahibine; hane dışına kapalı", async () => { const pluxee = await acc("Ayşe — Pluxee"); expect(await getAccountStatement(ayse, cakir, pluxee, currentMonth())).not.toBeNull(); expect(await getAccountStatement(emre, cakir, pluxee, currentMonth())).toBeNull(); expect(await getAccountStatement(fatma, cakir, await acc("Cüzdan"), currentMonth())).toBeNull(); }); it("gizli hesaba transfer ekstrede görünür ama karşı hesap adı gizlenir", async () => { const ziraat = await acc("Ziraat Maaş"); const pluxee = await acc("Ayşe — Pluxee"); const r = await createTransaction(ayse, cakir, { type: "transfer", amount: 123_45, date: todayIstanbul(), accountId: ziraat, toAccountId: pluxee, categoryId: null, splits: [], payee: "", description: "", tags: [], attachmentIds: [], installments: 1, } satisfies TransactionForm); expect(r.ok).toBe(true); const s = (await getAccountStatement(emre, cakir, ziraat, currentMonth()))!; const row = s.rows.find((x) => x.item.id === (r.ok ? r.data.id : ""))!; expect(row.restricted).toBe(true); expect(row.deltaMinor).toBe(-123_45); expect(row.item.toAccount!.name).not.toContain("Pluxee"); expect(s.currentBalanceMinor).toBe(await balanceOf(emre, ziraat)); }); }); describe("mutabakat", () => { it("bakiye tutuyorsa işlemler işaretlenir, düzeltme oluşmaz", async () => { const cuzdan = await acc("Cüzdan"); const today = todayIstanbul(); const balance = await balanceOf(ayse, cuzdan); const preview = await previewReconciliation(ayse, cakir, cuzdan, input(today, tl(balance))); expect(preview).toMatchObject({ ok: true, data: { differenceMinor: 0, appBalanceMinor: balance }, }); const unreconciled = preview.ok ? preview.data.unreconciledCount : 0; expect(unreconciled).toBeGreaterThan(0); const r = await reconcileAccount(ayse, cakir, cuzdan, input(today, tl(balance))); expect(r).toMatchObject({ ok: true, data: { adjustmentId: null, markedCount: unreconciled } }); const again = await reconcileAccount(ayse, cakir, cuzdan, input(today, tl(balance))); expect(again).toMatchObject({ ok: true, data: { markedCount: 0 } }); const s = (await getAccountStatement(ayse, cakir, cuzdan, currentMonth()))!; expect(s.rows.every((row) => row.reconciled)).toBe(true); expect(s.lastReconciliation).toMatchObject({ date: today, differenceMinor: 0 }); }); it("fark varsa onay ister; onaylanınca düzeltme işlemi oluşur ve bakiye ekstreyle eşitlenir", async () => { const ziraat = await acc("Ziraat Maaş"); const today = todayIstanbul(); const balance = await balanceOf(ayse, ziraat); const target = balance - 250_00; const refused = await reconcileAccount(ayse, cakir, ziraat, input(today, tl(target))); expect(refused.ok).toBe(false); expect(await ReconciliationModel.countDocuments({ accountId: ziraat })).toBe(0); const r = await reconcileAccount(ayse, cakir, ziraat, input(today, tl(target), true)); expect(r.ok).toBe(true); const adjustmentId = r.ok ? r.data.adjustmentId! : ""; const adj = (await TransactionModel.findById(adjustmentId).lean())!; expect(adj).toMatchObject({ type: "expense", amountMinor: 250_00, isAdjustment: true }); expect(await balanceOf(ayse, ziraat)).toBe(target); const audit = await AuditLogModel.findOne({ action: "account.reconcile", targetId: ziraat, }).lean(); expect(audit!.diff).toMatchObject({ differenceMinor: -250_00 }); // Düzeltme işlemi düzenlenemez, silinebilir. const detail = (await getTransaction(ayse, cakir, adjustmentId))!; expect(detail.isAdjustment).toBe(true); const edit = await updateTransaction(ayse, cakir, adjustmentId, { type: "expense", amount: 1_00, date: today, accountId: ziraat, toAccountId: null, categoryId: await cat("market"), splits: [], payee: "", description: "", tags: [], attachmentIds: [], installments: 1, }); expect(edit.ok).toBe(false); expect((await setTransactionDeleted(ayse, cakir, adjustmentId, true)).ok).toBe(true); }); it("kredi kartında borç pozitif girilir, uygulamada eksi bakiyeyle karşılaştırılır", async () => { const kart = await acc("Garanti Bonus"); const balance = await balanceOf(ayse, kart); expect(balance).toBeLessThan(0); const preview = await previewReconciliation( ayse, cakir, kart, input(todayIstanbul(), tl(-balance)), ); expect(preview).toMatchObject({ ok: true, data: { statementBalanceMinor: balance, differenceMinor: 0 }, }); }); it("işaretli işlemin tutarı değişince işaret kalkar; yalnızca açıklama değişince kalır", async () => { const cuzdan = await acc("Cüzdan"); const tx = await TransactionModel.findOne({ accountId: cuzdan, type: "expense", reconciledAccountIds: cuzdan, splits: { $size: 0 }, deletedAt: null, }).lean(); const d = (await getTransaction(ayse, cakir, String(tx!._id)))!; const base: TransactionForm = { type: "expense", amount: d.amountMinor, date: d.date, accountId: cuzdan, toAccountId: null, categoryId: d.categoryId, splits: [], payee: d.payeeName, description: "yeni açıklama", tags: d.tags, attachmentIds: [], installments: 1, }; expect((await updateTransaction(ayse, cakir, d.id, base)).ok).toBe(true); expect( (await TransactionModel.findById(d.id).lean())!.reconciledAccountIds.map(String), ).toEqual([cuzdan]); expect( (await updateTransaction(ayse, cakir, d.id, { ...base, amount: d.amountMinor + 1 })).ok, ).toBe(true); expect((await TransactionModel.findById(d.id).lean())!.reconciledAccountIds).toEqual([]); }); it("yetki ve doğrulama: izleyici, gizli hesap, gelecek tarih", async () => { const cuzdan = await acc("Cüzdan"); expect((await reconcileAccount(zeynep, cakir, cuzdan, input(todayIstanbul(), "0"))).ok).toBe( false, ); expect( (await reconcileAccount(emre, cakir, await acc("Ayşe — Pluxee"), input(todayIstanbul(), "0"))) .ok, ).toBe(false); expect((await previewReconciliation(ayse, cakir, cuzdan, input("2999-01-01", "0"))).ok).toBe( false, ); }); });