feat(assets): varlık lotları, ortalama maliyet ve kâr/zarar servisi
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
import { AssetModel } from "@/models/asset";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "009-assets",
|
||||||
|
description: "assets ve assetMovements index'leri; işlemlerde varlık alanları",
|
||||||
|
async up(db) {
|
||||||
|
await db
|
||||||
|
.collection("transactions")
|
||||||
|
.updateMany({ assetId: { $exists: false } }, { $set: { assetId: null } });
|
||||||
|
await AssetModel.createIndexes();
|
||||||
|
await AssetMovementModel.createIndexes();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -8,10 +8,11 @@ import { migration as m005 } from "./005-recurring";
|
|||||||
import { migration as m006 } from "./006-card-statements";
|
import { migration as m006 } from "./006-card-statements";
|
||||||
import { migration as m007 } from "./007-contacts";
|
import { migration as m007 } from "./007-contacts";
|
||||||
import { migration as m008 } from "./008-in-kind";
|
import { migration as m008 } from "./008-in-kind";
|
||||||
|
import { migration as m009 } from "./009-assets";
|
||||||
import type { Migration } from "./types";
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||||
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008];
|
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { getHouseholdActionContext } from "@/lib/dal";
|
||||||
|
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||||
|
import { addMovement, createAsset, deleteAsset, deleteMovement, updateAsset } from "./service";
|
||||||
|
|
||||||
|
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||||
|
|
||||||
|
function fieldErrors(error: z.ZodError) {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
const key = issue.path.join(".");
|
||||||
|
if (key && !out[key]) out[key] = issue.message;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run<T>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
input: unknown,
|
||||||
|
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<unknown>>,
|
||||||
|
): Promise<ActionResult<unknown>> {
|
||||||
|
const ctx = await getHouseholdActionContext("member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const parsed = schema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
|
||||||
|
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
|
||||||
|
if (result.ok) revalidatePath("/", "layout");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yeni varlık (assetId boş) veya mevcut varlığı düzenle. */
|
||||||
|
export async function saveAssetAction(assetId: unknown, input: unknown) {
|
||||||
|
if (assetId) {
|
||||||
|
const id = idSchema.safeParse(assetId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(assetFormSchema, input, (u, h, d) => updateAsset(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
return run(assetFormSchema, input, createAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAssetAction(assetId: unknown) {
|
||||||
|
const id = idSchema.safeParse(assetId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => deleteAsset(u, h, id.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMovementAction(assetId: unknown, input: unknown) {
|
||||||
|
const id = idSchema.safeParse(assetId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(movementFormSchema, input, (u, h, d) => addMovement(u, h, id.data, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMovementAction(movementId: unknown) {
|
||||||
|
const id = idSchema.safeParse(movementId);
|
||||||
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
|
return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data));
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import "server-only";
|
||||||
|
import mongoose, { Types } from "mongoose";
|
||||||
|
import { hasHouseholdRole } from "@/features/households/roles";
|
||||||
|
import { priceOn, type PriceQuote } from "@/features/in-kind/service";
|
||||||
|
import { loadContext, type Ctx } from "@/features/transactions/service";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||||
|
import { writeAudit } from "@/lib/audit";
|
||||||
|
import { toObjectId } from "@/lib/object-id";
|
||||||
|
import { AssetModel } from "@/models/asset";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { formatAssetQuantity, valueOf, type AssetKey } from "@/shared/assets";
|
||||||
|
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
|
import {
|
||||||
|
holdingSummary,
|
||||||
|
runMovements,
|
||||||
|
type HoldingSummary,
|
||||||
|
type MovementKind,
|
||||||
|
} from "@/shared/portfolio";
|
||||||
|
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||||
|
import type { AssetForm, MovementForm } from "@/shared/schemas/asset";
|
||||||
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Birikim ve varlık takibi (Bölüm 6.6). Okuma: viewer; yazma: member. Varlıklar hane ortaktır;
|
||||||
|
* "yalnızca ben" işaretli varlık ve hareketleri (bağlı hesap işlemleri dahil) yalnızca sahibine
|
||||||
|
* görünür. Değerleme alış fiyatıyla (Faz 0). Alım/satış hesaba bağlıysa transfer işlemi oluşur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const t = tr.assets;
|
||||||
|
|
||||||
|
type AssetDoc = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
assetKey: AssetKey;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
ownerUserId?: Types.ObjectId | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MovementDoc = {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
assetId: Types.ObjectId;
|
||||||
|
kind: MovementKind;
|
||||||
|
date: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPriceMinor?: number | null;
|
||||||
|
amountMinor: number;
|
||||||
|
accountId?: Types.ObjectId | null;
|
||||||
|
transactionId?: Types.ObjectId | null;
|
||||||
|
storage: string;
|
||||||
|
note: string;
|
||||||
|
inKindDebtId?: Types.ObjectId | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetView = {
|
||||||
|
id: string;
|
||||||
|
assetKey: AssetKey;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
label: string;
|
||||||
|
price: PriceQuote | null;
|
||||||
|
summary: HoldingSummary;
|
||||||
|
/** Son alımın saklama yeri. */
|
||||||
|
storage: string;
|
||||||
|
movementCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetTotals = {
|
||||||
|
valueMinor: number;
|
||||||
|
costMinor: number;
|
||||||
|
unrealizedMinor: number;
|
||||||
|
realizedMinor: number;
|
||||||
|
/** Fiyatı olmadığı için toplam değere katılmayan (elde miktarı olan) varlık sayısı. */
|
||||||
|
missingPrice: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MovementRow = {
|
||||||
|
id: string;
|
||||||
|
kind: MovementKind;
|
||||||
|
date: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPriceMinor: number | null;
|
||||||
|
amountMinor: number;
|
||||||
|
account: string | null;
|
||||||
|
storage: string;
|
||||||
|
note: string;
|
||||||
|
costOutMinor: number;
|
||||||
|
realizedMinor: number;
|
||||||
|
quantityAfter: number;
|
||||||
|
fromInKind: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetDetail = AssetView & { movements: MovementRow[]; canWrite: boolean };
|
||||||
|
|
||||||
|
const sortMovements = (a: MovementDoc, b: MovementDoc) =>
|
||||||
|
a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1;
|
||||||
|
|
||||||
|
function visibleAsset(ctx: Ctx, id: Types.ObjectId | string) {
|
||||||
|
const a = ctx.assetMap.get(String(id));
|
||||||
|
return a && !a.hidden && !a.deleted ? a : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMovements(householdId: Types.ObjectId, assetIds: Types.ObjectId[]) {
|
||||||
|
const docs = await AssetMovementModel.find({
|
||||||
|
householdId,
|
||||||
|
assetId: { $in: assetIds },
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<MovementDoc[]>();
|
||||||
|
const by = new Map<string, MovementDoc[]>();
|
||||||
|
for (const m of docs) {
|
||||||
|
const list = by.get(String(m.assetId)) ?? [];
|
||||||
|
list.push(m);
|
||||||
|
by.set(String(m.assetId), list);
|
||||||
|
}
|
||||||
|
for (const list of by.values()) list.sort(sortMovements);
|
||||||
|
return by;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toView(a: AssetDoc, movements: MovementDoc[], price: PriceQuote | null): AssetView {
|
||||||
|
const run = runMovements(movements);
|
||||||
|
const summary = holdingSummary(run, price?.buyMinor ?? null);
|
||||||
|
const storage = [...movements].reverse().find((m) => m.storage)?.storage ?? "";
|
||||||
|
return {
|
||||||
|
id: String(a._id),
|
||||||
|
assetKey: a.assetKey,
|
||||||
|
name: a.name,
|
||||||
|
note: a.note,
|
||||||
|
isPrivate: a.isPrivate,
|
||||||
|
label: formatAssetQuantity(a.assetKey, summary.quantity),
|
||||||
|
price,
|
||||||
|
summary,
|
||||||
|
storage,
|
||||||
|
movementCount: movements.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Görünür varlıklar ve toplamlar (net varlık ve dashboard da kullanır). */
|
||||||
|
export async function listAssets(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
): Promise<{ items: AssetView[]; totals: AssetTotals; canWrite: boolean } | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
return { ...(await assetViews(ctx)), canWrite: hasHouseholdRole(ctx.m.role, "member") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assetViews(ctx: Ctx, date = todayIstanbul()) {
|
||||||
|
const docs = (
|
||||||
|
await AssetModel.find({ householdId: ctx.m.householdId, deletedAt: null }).lean<AssetDoc[]>()
|
||||||
|
).filter((a) => visibleAsset(ctx, a._id));
|
||||||
|
const movements = await loadMovements(
|
||||||
|
ctx.m.householdId,
|
||||||
|
docs.map((a) => a._id),
|
||||||
|
);
|
||||||
|
const prices = new Map<string, PriceQuote | null>();
|
||||||
|
for (const key of new Set(docs.map((a) => a.assetKey)))
|
||||||
|
prices.set(key, await priceOn(ctx.m.householdId, key, date));
|
||||||
|
const items = docs
|
||||||
|
.map((a) => toView(a, movements.get(String(a._id)) ?? [], prices.get(a.assetKey) ?? null))
|
||||||
|
.sort(
|
||||||
|
(x, y) =>
|
||||||
|
Number(y.summary.quantity > 0) - Number(x.summary.quantity > 0) ||
|
||||||
|
(y.summary.currentValueMinor ?? 0) - (x.summary.currentValueMinor ?? 0) ||
|
||||||
|
compareTr(x.name, y.name),
|
||||||
|
);
|
||||||
|
const totals: AssetTotals = {
|
||||||
|
valueMinor: 0,
|
||||||
|
costMinor: 0,
|
||||||
|
unrealizedMinor: 0,
|
||||||
|
realizedMinor: 0,
|
||||||
|
missingPrice: 0,
|
||||||
|
};
|
||||||
|
for (const v of items) {
|
||||||
|
totals.realizedMinor += v.summary.realizedMinor;
|
||||||
|
if (v.summary.quantity === 0) continue;
|
||||||
|
if (v.summary.currentValueMinor === null) {
|
||||||
|
totals.missingPrice++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
totals.valueMinor += v.summary.currentValueMinor;
|
||||||
|
totals.costMinor += v.summary.costMinor;
|
||||||
|
totals.unrealizedMinor += v.summary.unrealizedMinor ?? 0;
|
||||||
|
}
|
||||||
|
return { items, totals };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAsset(ctx: Ctx, assetId: string) {
|
||||||
|
const id = toObjectId(assetId);
|
||||||
|
if (!id || !visibleAsset(ctx, id)) return null;
|
||||||
|
return AssetModel.findOne({
|
||||||
|
_id: id,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<AssetDoc>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAsset(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
assetId: string,
|
||||||
|
): Promise<AssetDetail | null> {
|
||||||
|
const ctx = await loadContext(userId, householdId, "viewer");
|
||||||
|
if (!ctx) return null;
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return null;
|
||||||
|
const movements = (await loadMovements(ctx.m.householdId, [a._id])).get(String(a._id)) ?? [];
|
||||||
|
const price = await priceOn(ctx.m.householdId, a.assetKey, todayIstanbul());
|
||||||
|
const run = runMovements(movements);
|
||||||
|
const rows = movements.map((m, i): MovementRow => {
|
||||||
|
const acc = m.accountId ? ctx.accountMap.get(String(m.accountId)) : undefined;
|
||||||
|
const step = run.steps[i]!;
|
||||||
|
return {
|
||||||
|
id: String(m._id),
|
||||||
|
kind: m.kind,
|
||||||
|
date: m.date,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unitPriceMinor: m.unitPriceMinor ?? null,
|
||||||
|
amountMinor: m.amountMinor,
|
||||||
|
account: acc ? (acc.hidden ? tr.transactions.list.hiddenAccount : acc.name) : null,
|
||||||
|
storage: m.storage,
|
||||||
|
note: m.note,
|
||||||
|
costOutMinor: step.costOutMinor,
|
||||||
|
realizedMinor: step.realizedMinor,
|
||||||
|
quantityAfter: step.quantityAfter,
|
||||||
|
fromInKind: Boolean(m.inKindDebtId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...toView(a, movements, price),
|
||||||
|
movements: rows.reverse(),
|
||||||
|
canWrite: hasHouseholdRole(ctx.m.role, "member"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Varlık kaydı ---
|
||||||
|
|
||||||
|
export async function createAsset(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
input: AssetForm,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const doc = await AssetModel.create({
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
assetKey: input.assetKey,
|
||||||
|
name: input.name,
|
||||||
|
note: input.note,
|
||||||
|
isPrivate: input.isPrivate,
|
||||||
|
ownerUserId: input.isPrivate ? actor : null,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
});
|
||||||
|
return ok({ id: String(doc._id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAsset(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
assetId: string,
|
||||||
|
input: AssetForm,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return fail(tr.common.notFound);
|
||||||
|
if (
|
||||||
|
input.assetKey !== a.assetKey &&
|
||||||
|
(await AssetMovementModel.exists({ assetId: a._id, deletedAt: null }))
|
||||||
|
)
|
||||||
|
return fail(t.errors.assetKeyLocked, { assetKey: t.errors.assetKeyLocked });
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const owner = a.ownerUserId ?? (input.isPrivate ? actor : null);
|
||||||
|
await AssetModel.updateOne(
|
||||||
|
{ _id: a._id, householdId: ctx.m.householdId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
assetKey: input.assetKey,
|
||||||
|
name: input.name,
|
||||||
|
note: input.note,
|
||||||
|
isPrivate: input.isPrivate,
|
||||||
|
ownerUserId: input.isPrivate ? owner : null,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAsset(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
assetId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return fail(tr.common.notFound);
|
||||||
|
if (await AssetMovementModel.exists({ assetId: a._id, deletedAt: null }))
|
||||||
|
return fail(t.errors.hasMovements);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await AssetModel.updateOne({ _id: a._id }, { $set: { deletedAt: new Date(), updatedBy: actor } });
|
||||||
|
await writeAudit({
|
||||||
|
action: "asset.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
targetType: "asset",
|
||||||
|
targetId: String(a._id),
|
||||||
|
diff: { name: a.name, assetKey: a.assetKey },
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hareketler ---
|
||||||
|
|
||||||
|
function checkAccount(ctx: Ctx, id: string | null) {
|
||||||
|
if (!id) return null;
|
||||||
|
const a = ctx.accountMap.get(id);
|
||||||
|
return a && !a.hidden && !a.archived ? a : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tutar ve birim fiyatı tamamlar (bkz. movementFormSchema). */
|
||||||
|
async function resolveAmount(
|
||||||
|
householdId: Types.ObjectId,
|
||||||
|
assetKey: AssetKey,
|
||||||
|
input: MovementForm,
|
||||||
|
): Promise<{ unitPriceMinor: number | null; amountMinor: number } | null> {
|
||||||
|
if (input.kind === "gift_out") return { unitPriceMinor: null, amountMinor: 0 };
|
||||||
|
if (input.amount !== null)
|
||||||
|
return {
|
||||||
|
amountMinor: input.amount,
|
||||||
|
unitPriceMinor:
|
||||||
|
input.unitPrice ?? Math.round((input.amount * QUANTITY_SCALE) / input.quantity),
|
||||||
|
};
|
||||||
|
let unit = input.unitPrice;
|
||||||
|
if (unit === null) {
|
||||||
|
const p = await priceOn(householdId, assetKey, input.date);
|
||||||
|
// Alımda kurumun satış fiyatı ödenir; satış ve hediye değeri alış fiyatıyla.
|
||||||
|
unit = p ? (input.kind === "buy" ? p.sellMinor : p.buyMinor) : null;
|
||||||
|
}
|
||||||
|
if (unit === null) return null;
|
||||||
|
return { unitPriceMinor: unit, amountMinor: valueOf(input.quantity, unit) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMovement(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
assetId: string,
|
||||||
|
input: MovementForm,
|
||||||
|
opts: { inKindDebtId?: Types.ObjectId; session?: mongoose.ClientSession } = {},
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const a = await loadAsset(ctx, assetId);
|
||||||
|
if (!a) return fail(tr.common.notFound);
|
||||||
|
if (input.date > todayIstanbul()) return fail(t.errors.futureDate, { date: t.errors.futureDate });
|
||||||
|
const date = istanbulDayStart(input.date);
|
||||||
|
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
|
||||||
|
const account = checkAccount(ctx, input.accountId);
|
||||||
|
if (input.accountId && !account)
|
||||||
|
return fail(tr.transactions.validation.accountInvalid, {
|
||||||
|
accountId: tr.transactions.validation.accountInvalid,
|
||||||
|
});
|
||||||
|
const amount = await resolveAmount(ctx.m.householdId, a.assetKey, input);
|
||||||
|
if (!amount) return fail(t.errors.noPrice, { amount: t.errors.noPrice });
|
||||||
|
|
||||||
|
// Eldeki miktar: yeni hareket aynı günün sonuna eklenmiş gibi doğrulanır.
|
||||||
|
const existing = (await loadMovements(ctx.m.householdId, [a._id])).get(String(a._id)) ?? [];
|
||||||
|
const now = new Date();
|
||||||
|
const candidate = [
|
||||||
|
...existing,
|
||||||
|
{ kind: input.kind, date: input.date, quantity: input.quantity, createdAt: now } as MovementDoc,
|
||||||
|
]
|
||||||
|
.map((m) => ({ ...m, amountMinor: m.amountMinor ?? 0 }))
|
||||||
|
.sort(sortMovements);
|
||||||
|
if (runMovements(candidate).negativeAt !== null)
|
||||||
|
return fail(t.errors.insufficient, { quantity: t.errors.insufficient });
|
||||||
|
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
const movementId = new Types.ObjectId();
|
||||||
|
const label = formatAssetQuantity(a.assetKey, input.quantity);
|
||||||
|
const write = async (session: mongoose.ClientSession) => {
|
||||||
|
let transactionId: Types.ObjectId | null = null;
|
||||||
|
if (account && amount.amountMinor > 0 && (input.kind === "buy" || input.kind === "sell")) {
|
||||||
|
const out = input.kind === "buy";
|
||||||
|
const description = t.txDescription[input.kind](label, a.name);
|
||||||
|
const [tx] = await TransactionModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
type: "transfer",
|
||||||
|
status: "posted",
|
||||||
|
date,
|
||||||
|
// Alım hesaptan çıkar (varlığa); satış hesaba girer (varlıktan).
|
||||||
|
accountId: out ? account.id : null,
|
||||||
|
toAccountId: out ? null : account.id,
|
||||||
|
amountMinor: amount.amountMinor,
|
||||||
|
description,
|
||||||
|
searchText: foldTr(description),
|
||||||
|
assetId: a._id,
|
||||||
|
createdBy: actor,
|
||||||
|
updatedBy: actor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
transactionId = tx!._id;
|
||||||
|
}
|
||||||
|
await AssetMovementModel.create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
_id: movementId,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
assetId: a._id,
|
||||||
|
kind: input.kind,
|
||||||
|
date: input.date,
|
||||||
|
quantity: input.quantity,
|
||||||
|
unitPriceMinor: amount.unitPriceMinor,
|
||||||
|
amountMinor: amount.amountMinor,
|
||||||
|
accountId: account && transactionId ? account.id : null,
|
||||||
|
transactionId,
|
||||||
|
storage: input.storage,
|
||||||
|
note: input.note,
|
||||||
|
inKindDebtId: opts.inKindDebtId ?? null,
|
||||||
|
createdBy: actor,
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (opts.session) await write(opts.session);
|
||||||
|
else await mongoose.connection.transaction(write);
|
||||||
|
return ok({ id: String(movementId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMovement(
|
||||||
|
actorId: string,
|
||||||
|
householdId: string,
|
||||||
|
movementId: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const ctx = await loadContext(actorId, householdId, "member");
|
||||||
|
if (!ctx) return fail(tr.common.unauthorized);
|
||||||
|
const id = toObjectId(movementId);
|
||||||
|
if (!id) return fail(tr.common.notFound);
|
||||||
|
const m = await AssetMovementModel.findOne({
|
||||||
|
_id: id,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
deletedAt: null,
|
||||||
|
}).lean<MovementDoc>();
|
||||||
|
if (!m || !visibleAsset(ctx, m.assetId)) return fail(tr.common.notFound);
|
||||||
|
const rest = (
|
||||||
|
(await loadMovements(ctx.m.householdId, [m.assetId])).get(String(m.assetId)) ?? []
|
||||||
|
).filter((x) => !x._id.equals(m._id));
|
||||||
|
if (runMovements(rest).negativeAt !== null) return fail(t.errors.deleteBreaks);
|
||||||
|
const actor = new Types.ObjectId(actorId);
|
||||||
|
await mongoose.connection.transaction(async (session) => {
|
||||||
|
const now = new Date();
|
||||||
|
await AssetMovementModel.updateOne({ _id: m._id }, { $set: { deletedAt: now } }, { session });
|
||||||
|
if (m.transactionId)
|
||||||
|
await TransactionModel.updateOne(
|
||||||
|
{ _id: m.transactionId, householdId: ctx.m.householdId },
|
||||||
|
{ $set: { deletedAt: now, updatedBy: actor } },
|
||||||
|
{ session },
|
||||||
|
);
|
||||||
|
await writeAudit(
|
||||||
|
{
|
||||||
|
action: "asset.movement.delete",
|
||||||
|
actorId: actor,
|
||||||
|
householdId: ctx.m.householdId,
|
||||||
|
targetType: "assetMovement",
|
||||||
|
targetId: String(m._id),
|
||||||
|
diff: { kind: m.kind, quantity: m.quantity, amountMinor: m.amountMinor, date: m.date },
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { writeAudit } from "@/lib/audit";
|
|||||||
import { getDb } from "@/lib/db/mongo";
|
import { getDb } from "@/lib/db/mongo";
|
||||||
import { toObjectId } from "@/lib/object-id";
|
import { toObjectId } from "@/lib/object-id";
|
||||||
import { AccountModel } from "@/models/account";
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { AssetModel } from "@/models/asset";
|
||||||
import { ContactModel } from "@/models/contact";
|
import { ContactModel } from "@/models/contact";
|
||||||
import { CategoryModel } from "@/models/category";
|
import { CategoryModel } from "@/models/category";
|
||||||
import { PayeeModel } from "@/models/payee";
|
import { PayeeModel } from "@/models/payee";
|
||||||
@@ -70,7 +71,7 @@ export async function loadContext(
|
|||||||
const m = await resolveMembership(userId, householdId, minRole);
|
const m = await resolveMembership(userId, householdId, minRole);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
await postDueInstallments(m.householdId);
|
await postDueInstallments(m.householdId);
|
||||||
const [accounts, categories, contacts] = await Promise.all([
|
const [accounts, categories, contacts, assets] = await Promise.all([
|
||||||
AccountModel.find({ householdId: m.householdId })
|
AccountModel.find({ householdId: m.householdId })
|
||||||
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
|
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
|
||||||
.lean(),
|
.lean(),
|
||||||
@@ -89,6 +90,9 @@ export async function loadContext(
|
|||||||
ContactModel.find({ householdId: m.householdId })
|
ContactModel.find({ householdId: m.householdId })
|
||||||
.select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 })
|
.select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 })
|
||||||
.lean(),
|
.lean(),
|
||||||
|
AssetModel.find({ householdId: m.householdId })
|
||||||
|
.select({ name: 1, assetKey: 1, isPrivate: 1, ownerUserId: 1, deletedAt: 1 })
|
||||||
|
.lean(),
|
||||||
]);
|
]);
|
||||||
const accountMap = new Map<string, AccountInfo>(
|
const accountMap = new Map<string, AccountInfo>(
|
||||||
accounts.map((a) => [
|
accounts.map((a) => [
|
||||||
@@ -143,6 +147,19 @@ export async function loadContext(
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const hiddenContactIds = [...contactMap.values()].filter((c) => c.hidden).map((c) => c.id);
|
const hiddenContactIds = [...contactMap.values()].filter((c) => c.hidden).map((c) => c.id);
|
||||||
|
const assetMap = new Map(
|
||||||
|
assets.map((a) => [
|
||||||
|
String(a._id),
|
||||||
|
{
|
||||||
|
id: a._id,
|
||||||
|
name: a.name,
|
||||||
|
assetKey: a.assetKey,
|
||||||
|
deleted: Boolean(a.deletedAt),
|
||||||
|
hidden: Boolean(a.isPrivate) && String(a.ownerUserId) !== userId,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const hiddenAssetIds = [...assetMap.values()].filter((a) => a.hidden).map((a) => a.id);
|
||||||
return {
|
return {
|
||||||
m,
|
m,
|
||||||
accountMap,
|
accountMap,
|
||||||
@@ -150,13 +167,15 @@ export async function loadContext(
|
|||||||
contactMap,
|
contactMap,
|
||||||
hiddenAccountIds,
|
hiddenAccountIds,
|
||||||
hiddenContactIds,
|
hiddenContactIds,
|
||||||
|
assetMap,
|
||||||
|
hiddenAssetIds,
|
||||||
flatCategories: flat,
|
flatCategories: flat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
|
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
|
||||||
|
|
||||||
/** Gizli hesaplara veya gizli carilere dokunan işlemleri dışlayan temel filtre. */
|
/** Gizli hesaplara, gizli carilere veya gizli varlıklara dokunan işlemleri dışlayan temel filtre. */
|
||||||
export function baseFilter(ctx: Ctx): Record<string, unknown> {
|
export function baseFilter(ctx: Ctx): Record<string, unknown> {
|
||||||
const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
|
const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
|
||||||
if (ctx.hiddenAccountIds.length) {
|
if (ctx.hiddenAccountIds.length) {
|
||||||
@@ -164,6 +183,7 @@ export function baseFilter(ctx: Ctx): Record<string, unknown> {
|
|||||||
f.toAccountId = { $nin: ctx.hiddenAccountIds };
|
f.toAccountId = { $nin: ctx.hiddenAccountIds };
|
||||||
}
|
}
|
||||||
if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds };
|
if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds };
|
||||||
|
if (ctx.hiddenAssetIds.length) f.assetId = { $nin: ctx.hiddenAssetIds };
|
||||||
return f;
|
return f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +493,7 @@ export async function updateTransaction(
|
|||||||
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
|
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
|
||||||
// Cari hareketi cari sayfasındaki formla düzenlenir (tür ve yön cariye özgü).
|
// Cari hareketi cari sayfasındaki formla düzenlenir (tür ve yön cariye özgü).
|
||||||
if (current.contactId) return fail(t.contactEditHint);
|
if (current.contactId) return fail(t.contactEditHint);
|
||||||
|
if (current.assetId) return fail(t.assetLocked);
|
||||||
const built = buildDoc(ctx, input, current);
|
const built = buildDoc(ctx, input, current);
|
||||||
if (!built.ok) return built;
|
if (!built.ok) return built;
|
||||||
const d = built.data as {
|
const d = built.data as {
|
||||||
@@ -534,6 +555,8 @@ export async function setTransactionDeleted(
|
|||||||
if (!current || Boolean(current.deletedAt) === deleted) return fail(tr.common.notFound);
|
if (!current || Boolean(current.deletedAt) === deleted) return fail(tr.common.notFound);
|
||||||
// Aynî borç ödemesi kaydın ödeme geçmişinin parçasıdır; tek başına silinmez.
|
// Aynî borç ödemesi kaydın ödeme geçmişinin parçasıdır; tek başına silinmez.
|
||||||
if (current.inKindRole === "payment") return fail(t.inKindPaymentLocked);
|
if (current.inKindRole === "payment") return fail(t.inKindPaymentLocked);
|
||||||
|
// Varlık alım/satışı hareketin parçasıdır; hareket Birikim sayfasından silinir.
|
||||||
|
if (current.assetId) return fail(t.assetLocked);
|
||||||
const actor = new Types.ObjectId(actorId);
|
const actor = new Types.ObjectId(actorId);
|
||||||
await TransactionModel.updateOne(
|
await TransactionModel.updateOne(
|
||||||
{ _id: current._id, householdId: ctx.m.householdId },
|
{ _id: current._id, householdId: ctx.m.householdId },
|
||||||
@@ -602,6 +625,8 @@ export type TransactionListItem = {
|
|||||||
toAccount: { id: string; name: string } | null;
|
toAccount: { id: string; name: string } | null;
|
||||||
/** Cari transferi: kişi ve hareket türü. */
|
/** Cari transferi: kişi ve hareket türü. */
|
||||||
contact: { id: string; name: string; kind: ContactEntryKind } | null;
|
contact: { id: string; name: string; kind: ContactEntryKind } | null;
|
||||||
|
/** Varlık alımı/satışı: karşı taraf varlık. */
|
||||||
|
asset: { id: string; name: string } | null;
|
||||||
category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null;
|
category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null;
|
||||||
splitCount: number;
|
splitCount: number;
|
||||||
payeeName: string;
|
payeeName: string;
|
||||||
@@ -683,6 +708,9 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
|
|||||||
kind: tx.contactKind,
|
kind: tx.contactKind,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
asset: tx.assetId
|
||||||
|
? { id: String(tx.assetId), name: ctx.assetMap.get(String(tx.assetId))?.name ?? "—" }
|
||||||
|
: null,
|
||||||
installment: tx.installment?.planId
|
installment: tx.installment?.planId
|
||||||
? {
|
? {
|
||||||
planId: String(tx.installment.planId),
|
planId: String(tx.installment.planId),
|
||||||
|
|||||||
+114
@@ -364,6 +364,7 @@ export const tr = {
|
|||||||
cancelInstallments: "Kalan taksitleri iptal et",
|
cancelInstallments: "Kalan taksitleri iptal et",
|
||||||
contactEditHint: "Cari hareketi cari sayfasından düzenlenir.",
|
contactEditHint: "Cari hareketi cari sayfasından düzenlenir.",
|
||||||
inKindPaymentLocked: "Aynî borç ödemesi silinemez; ödeme cari sayfasındaki kayda bağlıdır.",
|
inKindPaymentLocked: "Aynî borç ödemesi silinemez; ödeme cari sayfasındaki kayda bağlıdır.",
|
||||||
|
assetLocked: "Varlık alım/satış işlemi Birikim sayfasındaki hareketten yönetilir.",
|
||||||
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
|
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
|
||||||
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
|
||||||
noAccountsLink: "Hesaplara git",
|
noAccountsLink: "Hesaplara git",
|
||||||
@@ -640,6 +641,119 @@ export const tr = {
|
|||||||
account: "Hesap seçin",
|
account: "Hesap seçin",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
assets: {
|
||||||
|
title: "Birikim",
|
||||||
|
description:
|
||||||
|
"Altın, döviz ve ziynet birikimleri: alım lotları, satış ve çıkışlar, ortalama maliyet ve kâr/zarar. Değerleme alış fiyatıyla.",
|
||||||
|
new: "Varlık ekle",
|
||||||
|
asset: "Varlık",
|
||||||
|
open: "Varlığa git",
|
||||||
|
createTitle: "Yeni varlık",
|
||||||
|
editTitle: "Varlığı düzenle",
|
||||||
|
empty: "Henüz varlık yok. Bir varlık ekleyip ilk alımı girin.",
|
||||||
|
closedGroup: "Elde kalmayanlar",
|
||||||
|
privateBadge: "Yalnızca ben",
|
||||||
|
fields: {
|
||||||
|
asset: "Varlık türü",
|
||||||
|
name: "Ad",
|
||||||
|
namePlaceholder: "Ör. Gram altın birikimi",
|
||||||
|
note: "Not",
|
||||||
|
isPrivate: "Yalnızca ben görebilirim",
|
||||||
|
isPrivateHint: "İşaretlenirse bu varlık ve hareketleri hanedeki diğer üyelere görünmez.",
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
quantity: "Eldeki miktar",
|
||||||
|
avgCost: "Ortalama maliyet",
|
||||||
|
cost: "Maliyet",
|
||||||
|
currentValue: "Güncel değer",
|
||||||
|
unrealized: "Gerçekleşmemiş K/Z",
|
||||||
|
realized: "Gerçekleşmiş K/Z",
|
||||||
|
invested: "Yatırılan",
|
||||||
|
proceeds: "Satış geliri",
|
||||||
|
noPrice: "Fiyat yok",
|
||||||
|
price: (price: string, date: string) => `Güncel alış fiyatı ${price} · ${date}`,
|
||||||
|
perUnit: (unit: string) => `/ ${unit}`,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
value: "Toplam güncel değer",
|
||||||
|
cost: "Toplam maliyet",
|
||||||
|
unrealized: "Gerçekleşmemiş K/Z",
|
||||||
|
realized: "Gerçekleşmiş K/Z",
|
||||||
|
missingPrice: (n: number) => `${n} varlığın fiyatı yok; toplam değere katılmadı.`,
|
||||||
|
},
|
||||||
|
kinds: {
|
||||||
|
buy: "Alım",
|
||||||
|
gift_in: "Hediye / miras geldi",
|
||||||
|
sell: "Satış",
|
||||||
|
gift_out: "Hediye / çıkış",
|
||||||
|
},
|
||||||
|
kindHints: {
|
||||||
|
buy: "Para seçilen hesaptan çıkar (transfer; gider sayılmaz).",
|
||||||
|
gift_in: "Hesap hareketi oluşmaz; maliyet o günkü değerdir.",
|
||||||
|
sell: "Satış tutarı seçilen hesaba girer; ortalama maliyete göre kâr/zarar yazılır.",
|
||||||
|
gift_out: "Hesap hareketi oluşmaz; ortalama maliyetle elden çıkar.",
|
||||||
|
},
|
||||||
|
movement: {
|
||||||
|
add: "Hareket ekle",
|
||||||
|
title: (kind: string, name: string) => `${kind} · ${name}`,
|
||||||
|
date: "Tarih",
|
||||||
|
quantity: "Miktar",
|
||||||
|
unitPrice: "Birim fiyat (TL)",
|
||||||
|
amount: "Toplam tutar (TL)",
|
||||||
|
priceHint:
|
||||||
|
"Tutar veya birim fiyattan birini girin; ikisi de boşsa fiyat tablosu kullanılır (alımda satış, satışta alış fiyatı).",
|
||||||
|
giftValueHint: "Boş bırakılırsa fiyat tablosundaki o günkü alış fiyatı kullanılır.",
|
||||||
|
account: "Hesap",
|
||||||
|
accountNone: "— Hesap yok —",
|
||||||
|
accountHint: "Hesap seçilmezse hesap hareketi oluşmaz.",
|
||||||
|
storage: "Saklama yeri",
|
||||||
|
storagePlaceholder: "Ör. ev kasası, banka kiralık kasa",
|
||||||
|
note: "Not",
|
||||||
|
held: (q: string) => `Eldeki: ${q}`,
|
||||||
|
},
|
||||||
|
history: "Hareketler",
|
||||||
|
noMovements: "Henüz hareket yok.",
|
||||||
|
table: {
|
||||||
|
date: "Tarih",
|
||||||
|
kind: "Hareket",
|
||||||
|
quantity: "Miktar",
|
||||||
|
unitPrice: "Birim fiyat",
|
||||||
|
amount: "Tutar",
|
||||||
|
result: "K/Z",
|
||||||
|
balance: "Kalan",
|
||||||
|
},
|
||||||
|
deleteMovement: "Hareketi sil",
|
||||||
|
deleteMovementTitle: "Hareket silinsin mi?",
|
||||||
|
deleteMovementDescription:
|
||||||
|
"Hareket ve bağlı hesap işlemi silinir; ortalama maliyet ve kâr/zarar yeniden hesaplanır.",
|
||||||
|
delete: "Varlığı sil",
|
||||||
|
deleteTitle: "Varlık silinsin mi?",
|
||||||
|
deleteDescription: "Yalnızca hareketi olmayan varlık silinebilir.",
|
||||||
|
prices: "Fiyat tablosu",
|
||||||
|
back: "Birikim",
|
||||||
|
txDescription: {
|
||||||
|
buy: (label: string, name: string) => `Varlık alımı: ${label} (${name})`,
|
||||||
|
sell: (label: string, name: string) => `Varlık satışı: ${label} (${name})`,
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
saved: "Varlık kaydedildi.",
|
||||||
|
movement: "Hareket kaydedildi.",
|
||||||
|
movementDeleted: "Hareket silindi.",
|
||||||
|
deleted: "Varlık silindi.",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
noPrice: "Bu tarih için fiyat yok; tutar veya birim fiyat girin.",
|
||||||
|
insufficient: "Bu tarihte eldeki miktar yetersiz.",
|
||||||
|
deleteBreaks: "Bu hareket silinirse sonraki bir satış/çıkış eldeki miktarı aşar.",
|
||||||
|
hasMovements: "Hareketi olan varlık silinemez; önce hareketleri silin.",
|
||||||
|
assetKeyLocked: "Hareketi olan varlığın türü değiştirilemez.",
|
||||||
|
futureDate: "Gelecek tarihli hareket girilemez.",
|
||||||
|
privateOwner: "Yalnızca sahibi gizliliği değiştirebilir.",
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
noAccountForGift: "Hediye hareketinde hesap seçilmez.",
|
||||||
|
},
|
||||||
|
},
|
||||||
prices: {
|
prices: {
|
||||||
title: "Fiyat tablosu",
|
title: "Fiyat tablosu",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
import { MOVEMENT_KINDS } from "@/shared/portfolio";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varlık hareketi (lot): alım, gelen hediye, satış, hediye/çıkış. Alım ve satış bir hesaba
|
||||||
|
* bağlıysa transfer işlemi oluşturulur (`transactionId`; gelir/gider raporlarına girmez).
|
||||||
|
* Sıra: tarih, sonra oluşturulma anı.
|
||||||
|
*/
|
||||||
|
const assetMovementSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
assetId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
kind: { type: String, enum: MOVEMENT_KINDS, required: true },
|
||||||
|
/** "YYYY-MM-DD" (İstanbul). */
|
||||||
|
date: { type: String, required: true },
|
||||||
|
/** Binde bir. */
|
||||||
|
quantity: { type: Number, required: true, min: 1 },
|
||||||
|
unitPriceMinor: { type: Number, default: null },
|
||||||
|
/** Alımda ödenen, satışta alınan, gelen hediyede değer; çıkışta 0. */
|
||||||
|
amountMinor: { type: Number, default: 0, min: 0 },
|
||||||
|
accountId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
transactionId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
/** Saklama yeri (kasa, banka kiralık kasası …). */
|
||||||
|
storage: { type: String, default: "", maxlength: 120 },
|
||||||
|
note: { type: String, default: "", maxlength: 300 },
|
||||||
|
/** Aynî borç/alacak ödemesinden gelen hareket (Faz 9b). */
|
||||||
|
inKindDebtId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
deletedAt: { type: Date, default: null },
|
||||||
|
seedRef: { type: String, default: null },
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true, collection: "assetMovements" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assetMovementSchema.index({ householdId: 1, assetId: 1, deletedAt: 1, date: 1, createdAt: 1 });
|
||||||
|
assetMovementSchema.index(
|
||||||
|
{ householdId: 1, seedRef: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AssetMovement = InferSchemaType<typeof assetMovementSchema>;
|
||||||
|
|
||||||
|
export const AssetMovementModel: Model<AssetMovement> =
|
||||||
|
(models.AssetMovement as Model<AssetMovement> | undefined) ??
|
||||||
|
model<AssetMovement>("AssetMovement", assetMovementSchema);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
|
import { ASSET_KEYS } from "@/shared/assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varlık (Bölüm 6.6): bir varlık türünde tutulan birikim, ör. "Gram altın birikimi" veya
|
||||||
|
* "Düğün ziynetleri". Miktar ve maliyet `assetMovements`ten hesaplanır (ağırlıklı ortalama).
|
||||||
|
* "Yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür.
|
||||||
|
*/
|
||||||
|
const assetSchema = new Schema(
|
||||||
|
{
|
||||||
|
householdId: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
assetKey: { type: String, enum: ASSET_KEYS, required: true },
|
||||||
|
name: { type: String, required: true, trim: true, maxlength: 80 },
|
||||||
|
note: { type: String, default: "", maxlength: 500 },
|
||||||
|
isPrivate: { type: Boolean, default: false },
|
||||||
|
ownerUserId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
deletedAt: { type: Date, default: null },
|
||||||
|
seedRef: { type: String, default: null },
|
||||||
|
createdBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
updatedBy: { type: Schema.Types.ObjectId, required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: true, collection: "assets" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assetSchema.index({ householdId: 1, deletedAt: 1, name: 1 });
|
||||||
|
assetSchema.index(
|
||||||
|
{ householdId: 1, seedRef: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Asset = InferSchemaType<typeof assetSchema>;
|
||||||
|
|
||||||
|
export const AssetModel: Model<Asset> =
|
||||||
|
(models.Asset as Model<Asset> | undefined) ?? model<Asset>("Asset", assetSchema);
|
||||||
@@ -33,6 +33,8 @@ export const AUDIT_ACTIONS = [
|
|||||||
"inkind.payment",
|
"inkind.payment",
|
||||||
"inkind.close",
|
"inkind.close",
|
||||||
"inkind.delete",
|
"inkind.delete",
|
||||||
|
"asset.delete",
|
||||||
|
"asset.movement.delete",
|
||||||
] as const;
|
] as const;
|
||||||
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ const transactionSchema = new Schema(
|
|||||||
*/
|
*/
|
||||||
inKindDebtId: { type: Schema.Types.ObjectId, default: null },
|
inKindDebtId: { type: Schema.Types.ObjectId, default: null },
|
||||||
inKindRole: { type: String, enum: ["contribution", "payment", null], default: null },
|
inKindRole: { type: String, enum: ["contribution", "payment", null], default: null },
|
||||||
|
/**
|
||||||
|
* Varlık alımı (hesap → varlık) veya satışı (varlık → hesap): karşı hesap boş transfer.
|
||||||
|
* Hareketi varlık sayfasından yönetilir; görünürlük varlığa bağlıdır.
|
||||||
|
*/
|
||||||
|
assetId: { type: Schema.Types.ObjectId, default: null },
|
||||||
/**
|
/**
|
||||||
* Mutabakatı yapılmış hesaplar (Bölüm 6.1). Transfer iki hesaba dokunduğu için hesap bazında
|
* Mutabakatı yapılmış hesaplar (Bölüm 6.1). Transfer iki hesaba dokunduğu için hesap bazında
|
||||||
* tutulur. Tutar, tarih, tür veya hesap değişirse işaret kalkar.
|
* tutulur. Tutar, tarih, tür veya hesap değişirse işaret kalkar.
|
||||||
@@ -88,6 +93,7 @@ transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 });
|
|||||||
transactionSchema.index({ householdId: 1, tags: 1 });
|
transactionSchema.index({ householdId: 1, tags: 1 });
|
||||||
transactionSchema.index({ householdId: 1, contactId: 1, date: 1 }, { sparse: true });
|
transactionSchema.index({ householdId: 1, contactId: 1, date: 1 }, { sparse: true });
|
||||||
transactionSchema.index({ "installment.planId": 1 }, { sparse: true });
|
transactionSchema.index({ "installment.planId": 1 }, { sparse: true });
|
||||||
|
transactionSchema.index({ householdId: 1, assetId: 1 }, { sparse: true });
|
||||||
transactionSchema.index(
|
transactionSchema.index(
|
||||||
{ recurringRuleId: 1, occurrenceIndex: 1 },
|
{ recurringRuleId: 1, occurrenceIndex: 1 },
|
||||||
{ unique: true, partialFilterExpression: { recurringRuleId: { $type: "objectId" } } },
|
{ unique: true, partialFilterExpression: { recurringRuleId: { $type: "objectId" } } },
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatBp, holdingSummary, runMovements, type MovementInput } from "./portfolio";
|
||||||
|
|
||||||
|
const buy = (quantity: number, amountMinor: number): MovementInput => ({
|
||||||
|
kind: "buy",
|
||||||
|
quantity,
|
||||||
|
amountMinor,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ağırlıklı ortalama maliyet", () => {
|
||||||
|
it("örnek senaryo: 10 gr 25.000 TL'ye, her ay ekleme", () => {
|
||||||
|
// 10 gr × 2.500, 2 gr × 2.600, 2 gr × 2.700 → 14 gr, 35.600 TL; ort. 2.542,857…
|
||||||
|
const run = runMovements([buy(10_000, 25_000_00), buy(2_000, 5_200_00), buy(2_000, 5_400_00)]);
|
||||||
|
expect(run.quantity).toBe(14_000);
|
||||||
|
expect(run.costMinor).toBe(35_600_00);
|
||||||
|
expect(run.investedMinor).toBe(35_600_00);
|
||||||
|
const s = holdingSummary(run, 3_000_00);
|
||||||
|
expect(s.avgCostMinor).toBe(2_542_86);
|
||||||
|
expect(s.currentValueMinor).toBe(42_000_00);
|
||||||
|
expect(s.unrealizedMinor).toBe(6_400_00);
|
||||||
|
// 6.400 / 35.600 = %17,98
|
||||||
|
expect(s.unrealizedBp).toBe(1798);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("satış: ortalama maliyetle düşer, gerçekleşmiş kâr/zarar", () => {
|
||||||
|
const run = runMovements([
|
||||||
|
buy(10_000, 30_000_00),
|
||||||
|
buy(10_000, 40_000_00),
|
||||||
|
{ kind: "sell", quantity: 5_000, amountMinor: 20_000_00 },
|
||||||
|
]);
|
||||||
|
// ort. 3.500 → 5 gr maliyet 17.500; satış 20.000 → +2.500
|
||||||
|
expect(run.steps[2]).toMatchObject({ costOutMinor: 17_500_00, realizedMinor: 2_500_00 });
|
||||||
|
expect(run.quantity).toBe(15_000);
|
||||||
|
expect(run.costMinor).toBe(52_500_00);
|
||||||
|
expect(run.proceedsMinor).toBe(20_000_00);
|
||||||
|
// Kalan ortalama değişmez.
|
||||||
|
expect(holdingSummary(run, null).avgCostMinor).toBe(3_500_00);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hediye çıkışı K/Z yazmaz; gelen hediye yatırım sayılmaz; tamamı çıkınca maliyet sıfır", () => {
|
||||||
|
const run = runMovements([
|
||||||
|
buy(3_000, 9_000_00),
|
||||||
|
{ kind: "gift_in", quantity: 1_000, amountMinor: 3_300_00 },
|
||||||
|
{ kind: "gift_out", quantity: 2_000, amountMinor: 0 },
|
||||||
|
{ kind: "sell", quantity: 2_000, amountMinor: 7_000_00 },
|
||||||
|
]);
|
||||||
|
expect(run.investedMinor).toBe(9_000_00);
|
||||||
|
expect(run.giftInMinor).toBe(3_300_00);
|
||||||
|
// ort. 3.075 → çıkış 6.150, kalan 6.150; satış tümü → K/Z 850
|
||||||
|
expect(run.giftOutCostMinor).toBe(6_150_00);
|
||||||
|
expect(run.realizedMinor).toBe(850_00);
|
||||||
|
expect(run.quantity).toBe(0);
|
||||||
|
expect(run.costMinor).toBe(0);
|
||||||
|
const s = holdingSummary(run, 3_500_00);
|
||||||
|
expect(s).toMatchObject({ avgCostMinor: null, currentValueMinor: 0, unrealizedBp: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("eldekinden fazla çıkış işaretlenir", () => {
|
||||||
|
const run = runMovements([
|
||||||
|
buy(1_000, 100_00),
|
||||||
|
{ kind: "sell", quantity: 2_000, amountMinor: 1 },
|
||||||
|
]);
|
||||||
|
expect(run.negativeAt).toBe(1);
|
||||||
|
expect(runMovements([buy(1_000, 100_00)]).negativeAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("oran biçimi", () => {
|
||||||
|
expect(formatBp(1798)).toBe("+%17,98");
|
||||||
|
expect(formatBp(-350)).toBe("−%3,5");
|
||||||
|
expect(formatBp(0)).toBe("%0");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { QUANTITY_SCALE } from "./quantity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varlık takibi hesap motoru (Bölüm 6.6): ağırlıklı ortalama maliyet, gerçekleşmiş ve
|
||||||
|
* gerçekleşmemiş kâr/zarar. Miktarlar binde bir tamsayı, tutarlar kuruş.
|
||||||
|
*
|
||||||
|
* - Alım (`buy`): miktar ve maliyet artar; ödenen TL "yatırılan" sayılır.
|
||||||
|
* - Gelen hediye/miras (`gift_in`): miktar artar; maliyet = girildiği günkü değer (yatırım değil).
|
||||||
|
* - Satış (`sell`): ortalama maliyetle maliyet düşer; satış tutarı − düşen maliyet = gerçekleşmiş K/Z.
|
||||||
|
* - Hediye/çıkış (`gift_out`): ortalama maliyetle maliyet düşer; kâr/zarar yazılmaz.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MOVEMENT_KINDS = ["buy", "gift_in", "sell", "gift_out"] as const;
|
||||||
|
export type MovementKind = (typeof MOVEMENT_KINDS)[number];
|
||||||
|
|
||||||
|
export const isInflow = (k: MovementKind) => k === "buy" || k === "gift_in";
|
||||||
|
|
||||||
|
export type MovementInput = {
|
||||||
|
kind: MovementKind;
|
||||||
|
quantity: number;
|
||||||
|
/** Alımda ödenen, satışta alınan, gelen hediyede değer; çıkışta yok sayılır. */
|
||||||
|
amountMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MovementStep = {
|
||||||
|
/** Satış/çıkışta ortalama maliyetle düşülen maliyet. */
|
||||||
|
costOutMinor: number;
|
||||||
|
/** Yalnızca satışta: satış tutarı − düşülen maliyet. */
|
||||||
|
realizedMinor: number;
|
||||||
|
/** Hareketten sonraki miktar ve kalan maliyet. */
|
||||||
|
quantityAfter: number;
|
||||||
|
costAfterMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortfolioRun = {
|
||||||
|
steps: MovementStep[];
|
||||||
|
quantity: number;
|
||||||
|
/** Kalan miktarın maliyet esası. */
|
||||||
|
costMinor: number;
|
||||||
|
/** Alımlarda ödenen toplam TL. */
|
||||||
|
investedMinor: number;
|
||||||
|
/** Satışlardan alınan toplam TL. */
|
||||||
|
proceedsMinor: number;
|
||||||
|
realizedMinor: number;
|
||||||
|
/** Gelen hediyelerin girişteki değeri. */
|
||||||
|
giftInMinor: number;
|
||||||
|
/** Çıkışlarla düşen maliyet. */
|
||||||
|
giftOutCostMinor: number;
|
||||||
|
/** Miktarın eksiye düştüğü ilk hareketin sırası (geçerli dizide null). */
|
||||||
|
negativeAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hareketleri kronolojik sırada (çağıran sıralar) işler. */
|
||||||
|
export function runMovements(movements: MovementInput[]): PortfolioRun {
|
||||||
|
let quantity = 0;
|
||||||
|
let cost = 0;
|
||||||
|
const r: PortfolioRun = {
|
||||||
|
steps: [],
|
||||||
|
quantity: 0,
|
||||||
|
costMinor: 0,
|
||||||
|
investedMinor: 0,
|
||||||
|
proceedsMinor: 0,
|
||||||
|
realizedMinor: 0,
|
||||||
|
giftInMinor: 0,
|
||||||
|
giftOutCostMinor: 0,
|
||||||
|
negativeAt: null,
|
||||||
|
};
|
||||||
|
movements.forEach((m, i) => {
|
||||||
|
let costOut = 0;
|
||||||
|
let realized = 0;
|
||||||
|
if (isInflow(m.kind)) {
|
||||||
|
quantity += m.quantity;
|
||||||
|
cost += m.amountMinor;
|
||||||
|
if (m.kind === "buy") r.investedMinor += m.amountMinor;
|
||||||
|
else r.giftInMinor += m.amountMinor;
|
||||||
|
} else {
|
||||||
|
if (m.quantity > quantity) {
|
||||||
|
if (r.negativeAt === null) r.negativeAt = i;
|
||||||
|
costOut = cost;
|
||||||
|
} else {
|
||||||
|
costOut = m.quantity === quantity ? cost : Math.round((cost * m.quantity) / quantity);
|
||||||
|
}
|
||||||
|
quantity = Math.max(0, quantity - m.quantity);
|
||||||
|
cost -= costOut;
|
||||||
|
if (m.kind === "sell") {
|
||||||
|
realized = m.amountMinor - costOut;
|
||||||
|
r.proceedsMinor += m.amountMinor;
|
||||||
|
r.realizedMinor += realized;
|
||||||
|
} else r.giftOutCostMinor += costOut;
|
||||||
|
}
|
||||||
|
r.steps.push({
|
||||||
|
costOutMinor: costOut,
|
||||||
|
realizedMinor: realized,
|
||||||
|
quantityAfter: quantity,
|
||||||
|
costAfterMinor: cost,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
r.quantity = quantity;
|
||||||
|
r.costMinor = cost;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HoldingSummary = {
|
||||||
|
quantity: number;
|
||||||
|
costMinor: number;
|
||||||
|
/** Birim başına ağırlıklı ortalama maliyet (miktar 0 ise null). */
|
||||||
|
avgCostMinor: number | null;
|
||||||
|
/** Güncel alış fiyatıyla değer (fiyat yoksa null). */
|
||||||
|
currentValueMinor: number | null;
|
||||||
|
unrealizedMinor: number | null;
|
||||||
|
/** Gerçekleşmemiş K/Z oranı, baz puan (%12,34 → 1234); maliyet 0 ise null. */
|
||||||
|
unrealizedBp: number | null;
|
||||||
|
realizedMinor: number;
|
||||||
|
investedMinor: number;
|
||||||
|
proceedsMinor: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Özet: `priceMinor` değerleme fiyatıdır (Faz 0: varlıklar alış fiyatıyla). */
|
||||||
|
export function holdingSummary(run: PortfolioRun, priceMinor: number | null): HoldingSummary {
|
||||||
|
const avgCostMinor =
|
||||||
|
run.quantity > 0 ? Math.round((run.costMinor * QUANTITY_SCALE) / run.quantity) : null;
|
||||||
|
const currentValueMinor =
|
||||||
|
priceMinor === null ? null : Math.round((run.quantity * priceMinor) / QUANTITY_SCALE);
|
||||||
|
const unrealizedMinor = currentValueMinor === null ? null : currentValueMinor - run.costMinor;
|
||||||
|
const unrealizedBp =
|
||||||
|
unrealizedMinor === null || run.costMinor <= 0
|
||||||
|
? null
|
||||||
|
: Math.round((unrealizedMinor * 10_000) / run.costMinor);
|
||||||
|
return {
|
||||||
|
quantity: run.quantity,
|
||||||
|
costMinor: run.costMinor,
|
||||||
|
avgCostMinor,
|
||||||
|
currentValueMinor,
|
||||||
|
unrealizedMinor,
|
||||||
|
unrealizedBp,
|
||||||
|
realizedMinor: run.realizedMinor,
|
||||||
|
investedMinor: run.investedMinor,
|
||||||
|
proceedsMinor: run.proceedsMinor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baz puanı "%12,34" / "−%3,5" biçiminde. */
|
||||||
|
export function formatBp(bp: number): string {
|
||||||
|
const s = (Math.abs(bp) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 2 });
|
||||||
|
return `${bp < 0 ? "−" : bp > 0 ? "+" : ""}%${s}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { tr } from "../../i18n/tr";
|
||||||
|
import { ASSET_KEYS } from "../assets";
|
||||||
|
import { parseMoney } from "../money";
|
||||||
|
import { MOVEMENT_KINDS } from "../portfolio";
|
||||||
|
import { parseQuantity } from "../quantity";
|
||||||
|
|
||||||
|
const v = tr.validation;
|
||||||
|
const a = tr.assets.validation;
|
||||||
|
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
|
||||||
|
const objectId = z.string().regex(/^[a-f0-9]{24}$/i, tr.transactions.validation.selectAccount);
|
||||||
|
const emptyToNull = (x: unknown) => (x === "" || x === undefined ? null : x);
|
||||||
|
|
||||||
|
/** Boş → null; dolu → pozitif kuruş. */
|
||||||
|
const optionalMoney = z.preprocess(
|
||||||
|
emptyToNull,
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((s, ctx) => {
|
||||||
|
const minor = parseMoney(s);
|
||||||
|
if (minor === null || minor <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: v.money });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return minor;
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Varlık (birikim kalemi) formu. */
|
||||||
|
export const assetFormSchema = z.object({
|
||||||
|
assetKey: z.enum(ASSET_KEYS),
|
||||||
|
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
|
||||||
|
note: z.string().trim().max(500).default(""),
|
||||||
|
isPrivate: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
export type AssetFormInput = z.input<typeof assetFormSchema>;
|
||||||
|
export type AssetForm = z.output<typeof assetFormSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varlık hareketi. Tutar ve birim fiyat isteğe bağlı: tutar varsa birim fiyat ondan, yalnızca
|
||||||
|
* birim fiyat varsa tutar ondan hesaplanır; ikisi de boşsa fiyat tablosu kullanılır
|
||||||
|
* (alım → satış fiyatı, satış ve gelen hediye → alış fiyatı). Hesap yalnızca alım/satışta.
|
||||||
|
*/
|
||||||
|
export const movementFormSchema = z
|
||||||
|
.object({
|
||||||
|
kind: z.enum(MOVEMENT_KINDS),
|
||||||
|
date: ymd,
|
||||||
|
quantity: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.transform((s, ctx) => {
|
||||||
|
const q = parseQuantity(s);
|
||||||
|
if (q === null || q <= 0) {
|
||||||
|
ctx.addIssue({ code: "custom", message: tr.inKind.validation.quantity });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
}),
|
||||||
|
unitPrice: optionalMoney,
|
||||||
|
amount: optionalMoney,
|
||||||
|
accountId: z.preprocess(emptyToNull, objectId.nullable()),
|
||||||
|
storage: z.string().trim().max(120).default(""),
|
||||||
|
note: z.string().trim().max(300).default(""),
|
||||||
|
})
|
||||||
|
.superRefine((x, ctx) => {
|
||||||
|
if ((x.kind === "gift_in" || x.kind === "gift_out") && x.accountId)
|
||||||
|
ctx.addIssue({ code: "custom", path: ["accountId"], message: a.noAccountForGift });
|
||||||
|
})
|
||||||
|
.transform((x) =>
|
||||||
|
x.kind === "gift_out" ? { ...x, unitPrice: null, amount: null, accountId: null } : x,
|
||||||
|
);
|
||||||
|
export type MovementFormInput = z.input<typeof movementFormSchema>;
|
||||||
|
export type MovementForm = z.output<typeof movementFormSchema>;
|
||||||
Reference in New Issue
Block a user