Files
butcem/src/features/assets/service.int.test.ts
T

329 lines
12 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 { 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<ReturnType<typeof setupSeededDb>>;
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<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 : "";
}
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),
);
});
});