import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { listAccounts } from "@/features/accounts/service"; import { savePrice } from "@/features/in-kind/service"; import { listTransactions, setTransactionDeleted, updateTransaction, } from "@/features/transactions/service"; import { tr } from "@/i18n/tr"; import { AccountModel } from "@/models/account"; import { AssetModel } from "@/models/asset"; import { AssetMovementModel } from "@/models/asset-movement"; import { AssetPriceModel } from "@/models/asset-price"; import { AuditLogModel } from "@/models/audit-log"; import { TransactionModel } from "@/models/transaction"; import { todayIstanbul } from "@/shared/dates"; import { addDays } from "@/shared/recurrence"; import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset"; import { assetPriceFormSchema } from "@/shared/schemas/in-kind"; import { transactionFilterSchema, transactionFormSchema } from "@/shared/schemas/transaction"; import { setupSeededDb, teardownDb } from "@/test/seeded-db"; import { addMovement, createAsset, deleteAsset, deleteMovement, getAsset, listAssets, updateAsset, } from "./service"; let ids: Awaited>; let cakir = ""; let ayse = ""; let emre = ""; let zeynep = ""; let fatma = ""; const today = todayIstanbul(); const daysAgo = (n: number) => addDays(today, -n); 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"); // Seed örnekleri bu testin senaryolarını etkilemesin. await Promise.all([ AssetPriceModel.deleteMany({}), AssetModel.deleteMany({}), AssetMovementModel.deleteMany({}), TransactionModel.deleteMany({ assetId: { $ne: null } }), ]); }); afterAll(teardownDb); const acc = (name: string) => AccountModel.findOne({ householdId: cakir, name }) .lean() .then((a) => String(a!._id)); async function balance(accountId: string) { return (await listAccounts(ayse, cakir))!.accounts.find((a) => a.id === accountId)!.balanceMinor!; } const mv = (input: Record) => movementFormSchema.parse({ date: today, ...input }); async function asset(name: string, assetKey = "gram_gold", isPrivate = false, by = ayse) { const r = await createAsset(by, cakir, assetFormSchema.parse({ assetKey, name, isPrivate })); return r.ok ? r.data.id : ""; } describe("varlık lotları ve ortalama maliyet", () => { let gold = ""; it("alım hesaptan transfer olarak çıkar; gelir/gider toplamları değişmez", async () => { gold = await asset("Altın birikimi"); const ziraat = await acc("Ziraat Maaş"); const z0 = await balance(ziraat); const sums0 = (await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums; const r = await addMovement( ayse, cakir, gold, mv({ kind: "buy", date: daysAgo(60), quantity: "10", amount: "25.000", accountId: ziraat, storage: "Ev kasası", }), ); expect(r.ok).toBe(true); expect(await balance(ziraat)).toBe(z0 - 25_000_00); expect((await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums).toEqual( sums0, ); // İşlem listesinde varlık adıyla görünür. const page = (await listTransactions( ayse, cakir, transactionFilterSchema.parse({ q: "Varlık alımı" }), ))!; expect(page.items[0]).toMatchObject({ asset: { id: gold, name: "Altın birikimi" } }); }); it("birim fiyatla alım ve fiyat tablosundan (satış fiyatı) alım; özet", async () => { await addMovement( ayse, cakir, gold, mv({ kind: "buy", date: daysAgo(30), quantity: "2", unitPrice: "2.600" }), ); // Fiyat yok → reddedilir. expect( (await addMovement(ayse, cakir, gold, mv({ kind: "buy", date: daysAgo(10), quantity: "2" }))) .ok, ).toBe(false); await savePrice( ayse, cakir, assetPriceFormSchema.parse({ assetKey: "gram_gold", date: daysAgo(10), buy: "2.650", sell: "2.700", }), ); expect( (await addMovement(ayse, cakir, gold, mv({ kind: "buy", date: daysAgo(10), quantity: "2" }))) .ok, ).toBe(true); await savePrice( ayse, cakir, assetPriceFormSchema.parse({ assetKey: "gram_gold", date: today, buy: "3.000", sell: "3.100", }), ); const v = (await getAsset(ayse, cakir, gold))!; // 14 gr, 25.000 + 5.200 + 5.400 = 35.600; değer alış 3.000 ile 42.000. expect(v.summary).toMatchObject({ quantity: 14_000, costMinor: 35_600_00, investedMinor: 35_600_00, avgCostMinor: 2_542_86, currentValueMinor: 42_000_00, unrealizedMinor: 6_400_00, unrealizedBp: 1798, }); expect(v.storage).toBe("Ev kasası"); expect(v.movements[0]!.kind).toBe("buy"); }); it("satış: hesaba girer, ortalama maliyetle gerçekleşmiş K/Z; eldekinden fazlası reddedilir", async () => { const cuzdan = await acc("Cüzdan"); const c0 = await balance(cuzdan); expect( (await addMovement(ayse, cakir, gold, mv({ kind: "sell", quantity: "15", amount: "1" }))).ok, ).toBe(false); // Geçmiş tarihli satış o tarihteki miktarla sınırlı (60 gün önce yalnızca 10 gr vardı). expect( ( await addMovement( ayse, cakir, gold, mv({ kind: "sell", date: daysAgo(45), quantity: "11", amount: "1" }), ) ).ok, ).toBe(false); const r = await addMovement( ayse, cakir, gold, mv({ kind: "sell", quantity: "4", amount: "12.000", accountId: cuzdan }), ); expect(r.ok).toBe(true); expect(await balance(cuzdan)).toBe(c0 + 12_000_00); const v = (await getAsset(ayse, cakir, gold))!; // 4 gr maliyeti: 35.600 × 4/14 = 10.171,43 → K/Z 1.828,57 expect(v.movements[0]).toMatchObject({ kind: "sell", costOutMinor: 10_171_43, realizedMinor: 1_828_57, quantityAfter: 10_000, }); expect(v.summary.realizedMinor).toBe(1_828_57); expect(v.summary.proceedsMinor).toBe(12_000_00); }); it("hediye çıkışı K/Z yazmaz, hesap hareketi oluşturmaz", async () => { const before = (await getAsset(ayse, cakir, gold))!.summary; const r = await addMovement( ayse, cakir, gold, mv({ kind: "gift_out", quantity: "1", note: "Yeğene" }), ); expect(r.ok).toBe(true); const m = await AssetMovementModel.findById(r.ok ? r.data.id : "").lean(); expect(m).toMatchObject({ amountMinor: 0, transactionId: null }); const after = (await getAsset(ayse, cakir, gold))!.summary; expect(after.quantity).toBe(9_000); expect(after.realizedMinor).toBe(before.realizedMinor); expect(after.avgCostMinor).toBe(before.avgCostMinor); }); it("bağlı işlem tek başına düzenlenemez/silinemez; hareket silinince işlem de silinir", async () => { const tx = (await AssetMovementModel.findOne({ assetId: gold, kind: "sell" }).lean())!; const txId = String(tx.transactionId); const locked = { ok: false, error: tr.transactions.assetLocked }; expect(await setTransactionDeleted(ayse, cakir, txId, true)).toMatchObject(locked); const edit = transactionFormSchema.parse({ type: "transfer", date: today, amount: "1", accountId: await acc("Cüzdan"), toAccountId: await acc("Ziraat Maaş"), }); expect(await updateTransaction(ayse, cakir, txId, edit)).toMatchObject(locked); const cuzdan = await acc("Cüzdan"); const c0 = await balance(cuzdan); expect((await deleteMovement(ayse, cakir, String(tx._id))).ok).toBe(true); expect(await balance(cuzdan)).toBe(c0 - 12_000_00); expect( await AuditLogModel.exists({ action: "asset.movement.delete", targetId: String(tx._id) }), ).toBeTruthy(); expect((await getAsset(ayse, cakir, gold))!.summary.realizedMinor).toBe(0); }); it("silinince sonraki çıkış eldekini aşacaksa reddedilir; hareketli varlık silinemez", async () => { const first = (await AssetMovementModel.findOne({ assetId: gold, kind: "buy" }) .sort({ date: 1 }) .lean())!; // Kalan: 10 + 2 + 2 alım, bugün 1 gr çıkış. Sonraki alımlar silinebilir; ilk alım silinirse // çıkış eldekini aşar → reddedilir. const buys = await AssetMovementModel.find({ assetId: gold, kind: "buy", deletedAt: null }) .sort({ date: -1 }) .lean(); expect(buys.at(-1)!._id.equals(first._id)).toBe(true); for (const b of buys.slice(0, -1)) expect((await deleteMovement(ayse, cakir, String(b._id))).ok).toBe(true); expect((await deleteMovement(ayse, cakir, String(first._id))).ok).toBe(false); expect((await deleteAsset(ayse, cakir, gold)).ok).toBe(false); expect( (await updateAsset(ayse, cakir, gold, assetFormSchema.parse({ assetKey: "usd", name: "X" }))) .ok, ).toBe(false); }); it("gelen hediye: maliyet o günkü alış fiyatı, yatırım sayılmaz", async () => { const z = await asset("Düğün ziynetleri", "quarter_gold"); await savePrice( ayse, cakir, assetPriceFormSchema.parse({ assetKey: "quarter_gold", date: daysAgo(100), buy: "5.000" }), ); expect( movementFormSchema.safeParse({ kind: "gift_in", date: today, quantity: "1", accountId: await acc("Cüzdan"), }).success, ).toBe(false); expect( (await addMovement(ayse, cakir, z, mv({ kind: "gift_in", date: daysAgo(90), quantity: "4" }))) .ok, ).toBe(true); const v = (await getAsset(ayse, cakir, z))!; expect(v.summary).toMatchObject({ quantity: 4_000, costMinor: 20_000_00, investedMinor: 0 }); // Güncel değer: bugünden önceki en yakın fiyat. expect(v.summary.currentValueMinor).toBe(20_000_00); }); }); describe("görünürlük ve yetki", () => { it("gizli varlık ve bağlı işlemi başkasına görünmez; izleyici yazamaz; başka hane göremez", async () => { const secret = await asset("Emre dolar", "usd", true, emre); await savePrice( emre, cakir, assetPriceFormSchema.parse({ assetKey: "usd", date: today, buy: "40" }), ); const r = await addMovement( emre, cakir, secret, mv({ kind: "buy", quantity: "100", amount: "4.000", accountId: await acc("Cüzdan") }), ); expect(r.ok).toBe(true); expect((await listAssets(emre, cakir))!.items.some((a) => a.id === secret)).toBe(true); expect((await listAssets(ayse, cakir))!.items.some((a) => a.id === secret)).toBe(false); expect(await getAsset(ayse, cakir, secret)).toBeNull(); const ayseTx = (await listTransactions( ayse, cakir, transactionFilterSchema.parse({ q: "Emre dolar" }), ))!; expect(ayseTx.total).toBe(0); expect( (await addMovement(ayse, cakir, secret, mv({ kind: "gift_out", quantity: "1" }))).ok, ).toBe(false); const shared = (await listAssets(ayse, cakir))!.items[0]!.id; expect( (await addMovement(zeynep, cakir, shared, mv({ kind: "gift_out", quantity: "1" }))).ok, ).toBe(false); expect((await listAssets(zeynep, cakir))!.canWrite).toBe(false); expect(await listAssets(fatma, cakir)).toBeNull(); expect(await getAsset(fatma, cakir, shared)).toBeNull(); }); it("toplamlar: fiyatı olmayan varlık toplam değere katılmaz", async () => { const eur = await asset("Euro kumbara", "eur"); await addMovement(ayse, cakir, eur, mv({ kind: "gift_in", quantity: "50", amount: "2.400" })); const data = (await listAssets(ayse, cakir))!; expect(data.totals.missingPrice).toBe(1); const withPrice = data.items.filter((i) => i.summary.currentValueMinor !== null); expect(data.totals.valueMinor).toBe( withPrice.reduce((a, i) => a + (i.summary.currentValueMinor ?? 0), 0), ); }); });