Files
butcem/src/features/net-worth/service.int.test.ts
T

311 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
});
});