890ba9f521
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
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),
|
||
);
|
||
});
|
||
});
|