import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { addMovement, createAsset, getAsset, getPeriodReport } from "@/features/assets/service"; import { createContactEntry, createContact } from "@/features/contacts/service"; import { createInKind, deleteInKind, listInKind, recordPayment, savePrice, } from "@/features/in-kind/service"; import { AccountModel } from "@/models/account"; import { AssetModel } from "@/models/asset"; import { AssetMovementModel } from "@/models/asset-movement"; import { AssetPriceModel } from "@/models/asset-price"; import { InKindDebtModel } from "@/models/in-kind-debt"; import { TransactionModel } from "@/models/transaction"; import { todayIstanbul } from "@/shared/dates"; import { addDays, addMonths } from "@/shared/recurrence"; import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset"; import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact"; import { assetPriceFormSchema, inKindFormSchema, paymentFormSchema, } from "@/shared/schemas/in-kind"; import { setupSeededDb, teardownDb } from "@/test/seeded-db"; import { getNetWorth } from "./service"; let ids: Awaited>; let cakir = ""; let ayse = ""; let emre = ""; const today = todayIstanbul(); beforeAll(async () => { ids = await setupSeededDb(); cakir = ids.household("Çakır Ailesi"); ayse = ids.user("ayse"); emre = ids.user("emre"); // Seed varlık/fiyat/aynî örnekleri senaryoları etkilemesin. await Promise.all([ AssetPriceModel.deleteMany({}), AssetModel.deleteMany({}), AssetMovementModel.deleteMany({}), InKindDebtModel.deleteMany({}), TransactionModel.deleteMany({ $or: [{ assetId: { $ne: null } }, { inKindDebtId: { $ne: null } }], }), ]); }); afterAll(teardownDb); const acc = (name: string) => AccountModel.findOne({ householdId: cakir, name }) .lean() .then((a) => String(a!._id)); const price = (assetKey: string, date: string, buy: string, sell = "") => savePrice(ayse, cakir, assetPriceFormSchema.parse({ assetKey, date, buy, sell })); 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 : ""; } async function contact(name: string) { const r = await createContact( ayse, cakir, contactFormSchema.parse({ name, openingDate: addDays(today, -200) }), ); return r.ok ? r.data.id : ""; } const netWorth = async (user = ayse) => (await getNetWorth(user, cakir))!; describe("dönemsel rapor", () => { let gold = ""; it("son 6 ay: yatırılan, dönem sonu değer ve kâr; aylık noktalar", async () => { const m6 = addMonths(today, -6); await price("gram_gold", addDays(m6, -10), "2.500", "2.550"); await price("gram_gold", addMonths(today, -3), "2.800", "2.850"); await price("gram_gold", today, "3.000", "3.050"); gold = await asset("Rapor altın"); // Dönem öncesi 2 gr (başlangıç değeri), dönem içinde 10 gr + 1 gr. await addMovement( ayse, cakir, gold, mv({ kind: "buy", date: addDays(m6, -5), quantity: "2", amount: "5.000" }), ); await addMovement( ayse, cakir, gold, mv({ kind: "buy", date: addDays(m6, 3), quantity: "10", amount: "25.000" }), ); await addMovement( ayse, cakir, gold, mv({ kind: "buy", date: addMonths(today, -2), quantity: "1", amount: "2.850" }), ); const r = (await getPeriodReport(ayse, cakir, "6", gold))!; expect(r.from).toBe(m6); expect(r.startValueMinor).toBe(5_000_00); expect(r.investedMinor).toBe(27_850_00); expect(r.endValueMinor).toBe(39_000_00); expect(r.profitMinor).toBe(39_000_00 - 5_000_00 - 27_850_00); expect(r.points[0]!.date).toBe(m6); expect(r.points.at(-1)).toMatchObject({ date: today, valueMinor: 39_000_00, costMinor: 32_850_00, }); expect(r.points.length).toBeGreaterThanOrEqual(7); // "Tümü" ilk hareketten başlar; başlangıç değeri 0, kâr = değer − maliyet. const all = (await getPeriodReport(ayse, cakir, "tum", gold))!; expect(all.startValueMinor).toBe(0); expect(all.profitMinor).toBe(39_000_00 - 32_850_00); }); it("toplam rapor gizli varlığı başkasına katmaz; fiyatsız varlık dışarıda sayılır", async () => { const secret = await asset("Gizli altın", "gram_gold", true, emre); await addMovement(emre, cakir, secret, mv({ kind: "buy", quantity: "5", amount: "15.000" })); const silver = await asset("Gümüş", "silver_gram"); await addMovement( ayse, cakir, silver, mv({ kind: "gift_in", quantity: "100", amount: "4.000" }), ); const forAyse = (await getPeriodReport(ayse, cakir, "tum"))!; expect(forAyse.investedMinor).toBe(32_850_00); expect(forAyse.excluded).toBe(1); const forEmre = (await getPeriodReport(emre, cakir, "tum"))!; expect(forEmre.investedMinor).toBe(32_850_00 + 15_000_00); expect(await getPeriodReport(ayse, cakir, "6", secret)).toBeNull(); }); }); describe("net varlık", () => { it("formül: hesaplar − kart borcu + varlıklar + alacaklar − borçlar", async () => { const n = await netWorth(); expect(n.totalMinor).toBe( n.accountsMinor - n.cardDebtMinor + n.assetsMinor + n.contactReceivableMinor - n.contactPayableMinor + n.inKindReceivableMinor - n.inKindPayableMinor, ); // Gümüşün fiyatı yok → katılmaz ve sayılır. expect(n.missingPrice).toBe(1); // Emre kendi gizli altınını da görür: 5 × 3.000. expect((await netWorth(emre)).assetsMinor - n.assetsMinor).toBe(15_000_00); }); it("satış fiyatından alım net varlığı yalnızca makas kadar düşürür; cariden borç almak değiştirmez", async () => { const gold = await asset("Makas altın"); const n0 = (await netWorth()).totalMinor; await addMovement( ayse, cakir, gold, mv({ kind: "buy", quantity: "1", accountId: await acc("Ziraat Maaş") }), ); expect((await netWorth()).totalMinor).toBe(n0 - 50_00); const ali = await contact("Net Ali"); const n1 = (await netWorth()).totalMinor; await createContactEntry( ayse, cakir, ali, contactEntrySchema.parse({ kind: "borrow", accountId: await acc("Cüzdan"), amount: "1.000", date: today, }), ); const n2 = await netWorth(); expect(n2.totalMinor).toBe(n1); expect(n2.contactPayableMinor).toBeGreaterThanOrEqual(1_000_00); }); it("aynî borç satış, aynî alacak alış fiyatıyla düşülür/eklenir", async () => { const hasan = await contact("Net Hasan"); const n0 = (await netWorth()).totalMinor; await createInKind( ayse, cakir, hasan, inKindFormSchema.parse({ direction: "debt", assetKey: "gram_gold", quantity: "2", date: today, }), ); expect((await netWorth()).totalMinor).toBe(n0 - 6_100_00); await createInKind( ayse, cakir, hasan, inKindFormSchema.parse({ direction: "receivable", assetKey: "gram_gold", quantity: "1", date: today, }), ); expect((await netWorth()).totalMinor).toBe(n0 - 6_100_00 + 3_000_00); }); }); describe("aynî kayıt ↔ varlık bağlantısı", () => { it("borç aynı varlıkla ödenirken varlıktan düşer; yetersizse hiçbir şey yazılmaz", async () => { const kasa = await asset("Kasa altın"); await addMovement(ayse, cakir, kasa, mv({ kind: "buy", quantity: "3", amount: "9.000" })); const veli = await contact("Bağ Veli"); const debt = await createInKind( ayse, cakir, veli, inKindFormSchema.parse({ direction: "debt", assetKey: "gram_gold", quantity: "5", date: today, }), ); const debtId = debt.ok ? debt.data.id : ""; // 4 gr ödemek istiyor ama kasada 3 gr var → reddedilir, ödeme yazılmaz. const fail = await recordPayment( ayse, cakir, debtId, paymentFormSchema.parse({ method: "asset", date: today, quantity: "4", assetId: kasa }), ); expect(fail.ok).toBe(false); expect((await InKindDebtModel.findById(debtId).lean())!.payments).toHaveLength(0); const r = await recordPayment( ayse, cakir, debtId, paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: kasa }), ); expect(r).toMatchObject({ ok: true, data: { closed: false } }); const a = (await getAsset(ayse, cakir, kasa))!; expect(a.summary.quantity).toBe(1_000); expect(a.movements[0]).toMatchObject({ kind: "gift_out", quantity: 2_000, fromInKind: true }); expect((await listInKind(ayse, cakir, veli))![0]!.paidQuantity).toBe(2_000); // Kayıt silinince hareket serbest kalır. await deleteInKind(ayse, cakir, debtId); expect((await getAsset(ayse, cakir, kasa))!.movements[0]!.fromInKind).toBe(false); }); it("alacak aynı varlıkla tahsil edilince varlığa borç tarihindeki fiyatla eklenir", async () => { const ziynet = await asset("Ziynet kutusu", "quarter_gold"); const ayten = await contact("Bağ Ayten"); const rec = await createInKind( ayse, cakir, ayten, inKindFormSchema.parse({ direction: "receivable", assetKey: "quarter_gold", quantity: "2", date: addDays(today, -30), unitPrice: "5.000", }), ); const recId = rec.ok ? rec.data.id : ""; const gold = await asset("Yanlış tür"); expect( ( await recordPayment( ayse, cakir, recId, paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: gold }), ) ).ok, ).toBe(false); expect( paymentFormSchema.safeParse({ method: "cash", date: today, amount: "1", accountId: await acc("Cüzdan"), assetId: ziynet, }).success, ).toBe(false); const r = await recordPayment( ayse, cakir, recId, paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: ziynet }), ); expect(r).toMatchObject({ ok: true, data: { closed: true } }); const a = (await getAsset(ayse, cakir, ziynet))!; expect(a.summary).toMatchObject({ quantity: 2_000, costMinor: 10_000_00, investedMinor: 0 }); expect(a.movements[0]).toMatchObject({ kind: "gift_in", fromInKind: true }); }); });