9a60085d14
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
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 });
|
||
});
|
||
});
|