From 890ba9f5214fab3044c84232dff564ffcea249b6 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 23:17:44 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(assets):=20varl=C4=B1k=20lotlar=C4=B1,?= =?UTF-8?q?=20ortalama=20maliyet=20ve=20k=C3=A2r/zarar=20servisi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/migrations/009-assets.ts | 15 + scripts/migrations/index.ts | 3 +- src/features/assets/actions.ts | 62 +++ src/features/assets/service.int.test.ts | 328 ++++++++++++++++ src/features/assets/service.ts | 484 ++++++++++++++++++++++++ src/features/transactions/service.ts | 32 +- src/i18n/tr.ts | 114 ++++++ src/models/asset-movement.ts | 45 +++ src/models/asset.ts | 34 ++ src/models/audit-log.ts | 2 + src/models/transaction.ts | 6 + src/shared/portfolio.test.ts | 72 ++++ src/shared/portfolio.ts | 146 +++++++ src/shared/schemas/asset.ts | 75 ++++ 14 files changed, 1415 insertions(+), 3 deletions(-) create mode 100644 scripts/migrations/009-assets.ts create mode 100644 src/features/assets/actions.ts create mode 100644 src/features/assets/service.int.test.ts create mode 100644 src/features/assets/service.ts create mode 100644 src/models/asset-movement.ts create mode 100644 src/models/asset.ts create mode 100644 src/shared/portfolio.test.ts create mode 100644 src/shared/portfolio.ts create mode 100644 src/shared/schemas/asset.ts diff --git a/scripts/migrations/009-assets.ts b/scripts/migrations/009-assets.ts new file mode 100644 index 0000000..79c7a96 --- /dev/null +++ b/scripts/migrations/009-assets.ts @@ -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(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 8b84d58..2c8f076 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -8,10 +8,11 @@ import { migration as m005 } from "./005-recurring"; import { migration as m006 } from "./006-card-statements"; import { migration as m007 } from "./007-contacts"; import { migration as m008 } from "./008-in-kind"; +import { migration as m009 } from "./009-assets"; import type { Migration } from "./types"; /** 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"; diff --git a/src/features/assets/actions.ts b/src/features/assets/actions.ts new file mode 100644 index 0000000..1a06208 --- /dev/null +++ b/src/features/assets/actions.ts @@ -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 = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +async function run( + schema: z.ZodType, + input: unknown, + fn: (userId: string, householdId: string, data: T) => Promise>, +): Promise> { + 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)); +} diff --git a/src/features/assets/service.int.test.ts b/src/features/assets/service.int.test.ts new file mode 100644 index 0000000..c74fa74 --- /dev/null +++ b/src/features/assets/service.int.test.ts @@ -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>; +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) => 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), + ); + }); +}); diff --git a/src/features/assets/service.ts b/src/features/assets/service.ts new file mode 100644 index 0000000..2990e34 --- /dev/null +++ b/src/features/assets/service.ts @@ -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(); + const by = new Map(); + 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() + ).filter((a) => visibleAsset(ctx, a._id)); + const movements = await loadMovements( + ctx.m.householdId, + docs.map((a) => a._id), + ); + const prices = new Map(); + 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(); +} + +export async function getAsset( + userId: string, + householdId: string, + assetId: string, +): Promise { + 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> { + 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 { + 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 { + 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> { + 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 { + 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(); + 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); +} diff --git a/src/features/transactions/service.ts b/src/features/transactions/service.ts index ba845e1..7b1f423 100644 --- a/src/features/transactions/service.ts +++ b/src/features/transactions/service.ts @@ -13,6 +13,7 @@ import { writeAudit } from "@/lib/audit"; import { getDb } from "@/lib/db/mongo"; import { toObjectId } from "@/lib/object-id"; import { AccountModel } from "@/models/account"; +import { AssetModel } from "@/models/asset"; import { ContactModel } from "@/models/contact"; import { CategoryModel } from "@/models/category"; import { PayeeModel } from "@/models/payee"; @@ -70,7 +71,7 @@ export async function loadContext( const m = await resolveMembership(userId, householdId, minRole); if (!m) return null; await postDueInstallments(m.householdId); - const [accounts, categories, contacts] = await Promise.all([ + const [accounts, categories, contacts, assets] = await Promise.all([ AccountModel.find({ householdId: m.householdId }) .select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 }) .lean(), @@ -89,6 +90,9 @@ export async function loadContext( ContactModel.find({ householdId: m.householdId }) .select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 }) .lean(), + AssetModel.find({ householdId: m.householdId }) + .select({ name: 1, assetKey: 1, isPrivate: 1, ownerUserId: 1, deletedAt: 1 }) + .lean(), ]); const accountMap = new Map( accounts.map((a) => [ @@ -143,6 +147,19 @@ export async function loadContext( ]), ); 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 { m, accountMap, @@ -150,13 +167,15 @@ export async function loadContext( contactMap, hiddenAccountIds, hiddenContactIds, + assetMap, + hiddenAssetIds, flatCategories: flat, }; } export type Ctx = NonNullable>>; -/** 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 { const f: Record = { householdId: ctx.m.householdId, deletedAt: null }; if (ctx.hiddenAccountIds.length) { @@ -164,6 +183,7 @@ export function baseFilter(ctx: Ctx): Record { f.toAccountId = { $nin: ctx.hiddenAccountIds }; } if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds }; + if (ctx.hiddenAssetIds.length) f.assetId = { $nin: ctx.hiddenAssetIds }; return f; } @@ -473,6 +493,7 @@ export async function updateTransaction( if (current.isAdjustment) return fail(t.adjustmentReadOnly); // Cari hareketi cari sayfasındaki formla düzenlenir (tür ve yön cariye özgü). if (current.contactId) return fail(t.contactEditHint); + if (current.assetId) return fail(t.assetLocked); const built = buildDoc(ctx, input, current); if (!built.ok) return built; const d = built.data as { @@ -534,6 +555,8 @@ export async function setTransactionDeleted( 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. 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); await TransactionModel.updateOne( { _id: current._id, householdId: ctx.m.householdId }, @@ -602,6 +625,8 @@ export type TransactionListItem = { toAccount: { id: string; name: string } | null; /** Cari transferi: kişi ve hareket türü. */ 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; splitCount: number; payeeName: string; @@ -683,6 +708,9 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map): Transa kind: tx.contactKind, } : null, + asset: tx.assetId + ? { id: String(tx.assetId), name: ctx.assetMap.get(String(tx.assetId))?.name ?? "—" } + : null, installment: tx.installment?.planId ? { planId: String(tx.installment.planId), diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index 39b02e0..dc5bf8c 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -364,6 +364,7 @@ export const tr = { cancelInstallments: "Kalan taksitleri iptal et", contactEditHint: "Cari hareketi cari sayfasından düzenlenir.", 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.", noAccounts: "İşlem girmek için önce bir hesap gerekli.", noAccountsLink: "Hesaplara git", @@ -640,6 +641,119 @@ export const tr = { 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: { title: "Fiyat tablosu", description: diff --git a/src/models/asset-movement.ts b/src/models/asset-movement.ts new file mode 100644 index 0000000..fbc8ff5 --- /dev/null +++ b/src/models/asset-movement.ts @@ -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; + +export const AssetMovementModel: Model = + (models.AssetMovement as Model | undefined) ?? + model("AssetMovement", assetMovementSchema); diff --git a/src/models/asset.ts b/src/models/asset.ts new file mode 100644 index 0000000..793192e --- /dev/null +++ b/src/models/asset.ts @@ -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; + +export const AssetModel: Model = + (models.Asset as Model | undefined) ?? model("Asset", assetSchema); diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index 730811a..049f4e3 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -33,6 +33,8 @@ export const AUDIT_ACTIONS = [ "inkind.payment", "inkind.close", "inkind.delete", + "asset.delete", + "asset.movement.delete", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/models/transaction.ts b/src/models/transaction.ts index 98b0770..21a2efc 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -64,6 +64,11 @@ const transactionSchema = new Schema( */ inKindDebtId: { type: Schema.Types.ObjectId, 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 * 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, contactId: 1, date: 1 }, { sparse: true }); transactionSchema.index({ "installment.planId": 1 }, { sparse: true }); +transactionSchema.index({ householdId: 1, assetId: 1 }, { sparse: true }); transactionSchema.index( { recurringRuleId: 1, occurrenceIndex: 1 }, { unique: true, partialFilterExpression: { recurringRuleId: { $type: "objectId" } } }, diff --git a/src/shared/portfolio.test.ts b/src/shared/portfolio.test.ts new file mode 100644 index 0000000..7451236 --- /dev/null +++ b/src/shared/portfolio.test.ts @@ -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"); + }); +}); diff --git a/src/shared/portfolio.ts b/src/shared/portfolio.ts new file mode 100644 index 0000000..3ca7c22 --- /dev/null +++ b/src/shared/portfolio.ts @@ -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}`; +} diff --git a/src/shared/schemas/asset.ts b/src/shared/schemas/asset.ts new file mode 100644 index 0000000..2c9cbbf --- /dev/null +++ b/src/shared/schemas/asset.ts @@ -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; +export type AssetForm = z.output; + +/** + * 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; +export type MovementForm = z.output; From a818c110cfbeea77f9263e7f7c1db8c2678caadd Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 23:17:44 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(ui):=20birikim=20sayfas=C4=B1,=20varl?= =?UTF-8?q?=C4=B1k=20detay=C4=B1=20ve=20hareket=20diyaloglar=C4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/birikim/[id]/page.tsx | 17 + src/app/(app)/birikim/page.tsx | 22 ++ src/components/layout/nav-config.ts | 4 +- .../assets/components/asset-detail-view.tsx | 307 +++++++++++++++++ .../assets/components/asset-dialogs.tsx | 315 ++++++++++++++++++ .../assets/components/assets-view.tsx | 191 +++++++++++ .../in-kind/components/in-kind-section.tsx | 2 +- .../components/transaction-parts.tsx | 23 +- 8 files changed, 877 insertions(+), 4 deletions(-) create mode 100644 src/app/(app)/birikim/[id]/page.tsx create mode 100644 src/app/(app)/birikim/page.tsx create mode 100644 src/features/assets/components/asset-detail-view.tsx create mode 100644 src/features/assets/components/asset-dialogs.tsx create mode 100644 src/features/assets/components/assets-view.tsx diff --git a/src/app/(app)/birikim/[id]/page.tsx b/src/app/(app)/birikim/[id]/page.tsx new file mode 100644 index 0000000..3f5c7f2 --- /dev/null +++ b/src/app/(app)/birikim/[id]/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { AssetDetailView } from "@/features/assets/components/asset-detail-view"; +import { getAsset } from "@/features/assets/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.assets.history }; + +/** /birikim/:id — varlık hareketleri (gizli varlık yalnızca sahibine; aksi 404). */ +export default async function AssetPage({ params }: PageProps<"/birikim/[id]">) { + const { user, household } = await requireActiveHousehold("viewer"); + const { id } = await params; + const data = await getAsset(user.id, household.id, id); + if (!data) notFound(); + return ; +} diff --git a/src/app/(app)/birikim/page.tsx b/src/app/(app)/birikim/page.tsx new file mode 100644 index 0000000..5372c00 --- /dev/null +++ b/src/app/(app)/birikim/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { PageHeader } from "@/components/page-header"; +import { AssetsView } from "@/features/assets/components/assets-view"; +import { listAssets } from "@/features/assets/service"; +import { tr } from "@/i18n/tr"; +import { requireActiveHousehold } from "@/lib/dal"; + +export const metadata: Metadata = { title: tr.assets.title }; + +/** /birikim — altın, döviz ve ziynet varlıkları; ortalama maliyet ve kâr/zarar. */ +export default async function AssetsPage() { + const { user, household } = await requireActiveHousehold("viewer"); + const data = await listAssets(user.id, household.id); + if (!data) notFound(); + return ( + <> + + + + ); +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts index 4783174..f4251e3 100644 --- a/src/components/layout/nav-config.ts +++ b/src/components/layout/nav-config.ts @@ -27,7 +27,7 @@ export type NavItem = { /** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ /** Alt fazlar ondalıkla: 6.1 = Faz 6a. */ -export const CURRENT_PHASE = 8.2; +export const CURRENT_PHASE = 9.1; export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; @@ -38,7 +38,7 @@ export const MAIN_NAV: NavItem[] = [ { href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 }, { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 }, { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 }, - { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 }, + { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 }, { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 }, { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 }, { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 }, diff --git a/src/features/assets/components/asset-detail-view.tsx b/src/features/assets/components/asset-detail-view.tsx new file mode 100644 index 0000000..6d01d43 --- /dev/null +++ b/src/features/assets/components/asset-detail-view.tsx @@ -0,0 +1,307 @@ +"use client"; + +import { + ArrowLeft, + EllipsisVertical, + Gift, + HandCoins, + Lock, + Pencil, + ShoppingBag, + Trash2, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { PageHeader } from "@/components/page-header"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { tr } from "@/i18n/tr"; +import { ASSETS, formatAssetQuantity } from "@/shared/assets"; +import { formatDate, formatMoney } from "@/shared/format"; +import { isInflow, type MovementKind } from "@/shared/portfolio"; +import { deleteAssetAction, deleteMovementAction } from "../actions"; +import type { AssetDetail, MovementRow } from "../service"; +import { AssetFormDialog, MovementDialog } from "./asset-dialogs"; +import { ProfitLoss, Stat } from "./assets-view"; + +const t = tr.assets; +const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`); + +type Pending = + | { kind: "edit" } + | { kind: "movement"; movement: MovementKind } + | { kind: "deleteMovement"; row: MovementRow } + | { kind: "deleteAsset" } + | null; + +/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */ +export function AssetDetailView({ data }: { data: AssetDetail }) { + const router = useRouter(); + const [pending, setPending] = useState(null); + const close = () => setPending(null); + const s = data.summary; + const unit = ASSETS[data.assetKey].unit; + + return ( +
+ + + + + + + setPending({ kind: "edit" })}> + + {tr.common.edit} + + + 0} + onSelect={() => setPending({ kind: "deleteAsset" })} + > + + {t.delete} + + + + ) : null + } + /> + {data.isPrivate ? ( + + + {t.privateBadge} + + ) : null} + + + +
+ {formatAssetQuantity(data.assetKey, s.quantity)} + + {s.avgCostMinor !== null + ? `${formatMoney(s.avgCostMinor)} ${t.stats.perUnit(unit)}` + : "—"} + + {formatMoney(s.costMinor)} + + {s.currentValueMinor !== null ? formatMoney(s.currentValueMinor) : t.stats.noPrice} + + + + + + + + {formatMoney(s.investedMinor)} + {formatMoney(s.proceedsMinor)} +
+ {data.price ? ( +

+ {t.stats.price(formatMoney(data.price.buyMinor), d(data.price.date))} ·{" "} + + {t.prices} + +

+ ) : null} + {data.storage ? ( +

+ {t.movement.storage}: {data.storage} +

+ ) : null} + {data.note ?

{data.note}

: null} + {data.canWrite ? ( +
+ + {s.quantity > 0 ? ( + + ) : null} + +
+ ) : null} +
+
+ +
+

+ {t.history} +

+ {data.movements.length === 0 ? ( +

+ {t.noMovements} +

+ ) : ( +
    + {data.movements.map((row) => ( + setPending({ kind: "deleteMovement", row })} + /> + ))} +
+ )} +
+ + {pending?.kind === "edit" ? : null} + {pending?.kind === "movement" ? ( + + ) : null} + !o && close()} + > + + + + {pending?.kind === "deleteAsset" ? t.deleteTitle : t.deleteMovementTitle} + + + {pending?.kind === "deleteAsset" ? t.deleteDescription : t.deleteMovementDescription} + + + + {tr.common.cancel} + + + + +
+ ); +} + +function MovementItem({ + row, + data, + canWrite, + onDelete, +}: { + row: MovementRow; + data: AssetDetail; + canWrite: boolean; + onDelete: () => void; +}) { + const inflow = isInflow(row.kind); + const qty = formatAssetQuantity(data.assetKey, row.quantity); + const unit = ASSETS[data.assetKey].unit; + return ( +
  • +
    +

    + {t.kinds[row.kind]} + + {inflow ? "+" : "−"} + {qty} + +

    +

    + {d(row.date)} + {row.unitPriceMinor !== null + ? ` · ${formatMoney(row.unitPriceMinor)} ${t.stats.perUnit(unit)}` + : ""} + {row.account ? ` · ${row.account}` : ""} + {row.storage ? ` · ${row.storage}` : ""} +

    + {row.note ?

    {row.note}

    : null} +

    + {t.table.balance}: {formatAssetQuantity(data.assetKey, row.quantityAfter)} +

    +
    +
    + {row.kind !== "gift_out" ? ( + {formatMoney(row.amountMinor)} + ) : ( + + {t.stats.cost} {formatMoney(row.costOutMinor)} + + )} + {row.kind === "sell" ? ( + + {t.table.result}: + + ) : null} +
    + {canWrite && !row.fromInKind ? ( + + ) : null} +
  • + ); +} diff --git a/src/features/assets/components/asset-dialogs.tsx b/src/features/assets/components/asset-dialogs.tsx new file mode 100644 index 0000000..ad01c25 --- /dev/null +++ b/src/features/assets/components/asset-dialogs.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { MoneyInput } from "@/components/money-input"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useAccounts } from "@/features/in-kind/components/in-kind-section"; +import { tr } from "@/i18n/tr"; +import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets"; +import { todayIstanbul } from "@/shared/dates"; +import { MOVEMENT_KINDS, type MovementKind } from "@/shared/portfolio"; +import { addMovementAction, saveAssetAction } from "../actions"; +import type { AssetView } from "../service"; + +const t = tr.assets; +type Errors = Record; +const NONE = "__none__"; + +function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) { + return ( + <> + + + + ); +} + +/** Yeni varlık / varlığı düzenle. */ +export function AssetFormDialog({ + item, + onClose, + onCreated, +}: { + item: AssetView | null; + onClose: () => void; + onCreated?: (id: string) => void; +}) { + const [v, setV] = useState({ + assetKey: (item?.assetKey ?? "gram_gold") as AssetKey, + name: item?.name ?? "", + note: item?.note ?? "", + isPrivate: item?.isPrivate ?? false, + }); + const [errors, setErrors] = useState({}); + const [busy, setBusy] = useState(false); + const set = (k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x })); + const locked = Boolean(item?.movementCount); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + const r = await saveAssetAction(item?.id ?? null, { + ...v, + name: v.name.trim() || ASSETS[v.assetKey].label, + }); + setBusy(false); + if (r.ok) { + toast.success(t.toast.saved); + onClose(); + if (!item) onCreated?.((r.data as { id: string }).id); + } else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error }); + } + + const f = t.fields; + return ( + !o && onClose()} + title={item ? t.editTitle : t.createTitle} + footer={