feat(assets): dönemsel rapor, net varlık ve aynî kayıt bağlantısı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 00:19:05 +03:00
parent 20e5a90df4
commit 9a60085d14
9 changed files with 806 additions and 19 deletions
+310
View File
@@ -0,0 +1,310 @@
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<ReturnType<typeof setupSeededDb>>;
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<string, unknown>) => 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 });
});
});