import "server-only"; import mongoose, { Types } from "mongoose"; import { hasHouseholdRole } from "@/features/households/roles"; import { buyPriceLookup, priceOn, type PriceQuote } from "@/features/in-kind/prices"; 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, periodReport, runMovements, sumReports, type HoldingSummary, type MovementKind, type PeriodReport, } from "@/shared/portfolio"; import { addDays, addMonths } from "@/shared/recurrence"; 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); } // --- Dönemsel rapor --- export const REPORT_RANGES = ["3", "6", "12", "tum"] as const; export type ReportRange = (typeof REPORT_RANGES)[number]; /** * Seçilen aralıkta (son N ay veya ilk hareketten bugüne) yatırılan TL, dönem sonu değer, * kâr/zarar ve aylık değer noktaları. `assetId` verilirse yalnızca o varlık, yoksa tüm görünür * varlıkların toplamı. */ export async function getPeriodReport( userId: string, householdId: string, range: ReportRange, assetId?: string, ): Promise<(PeriodReport & { excluded: number }) | null> { const ctx = await loadContext(userId, householdId, "viewer"); if (!ctx) return null; let docs: AssetDoc[]; if (assetId) { const a = await loadAsset(ctx, assetId); if (!a) return null; docs = [a]; } else { 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 to = todayIstanbul(); let from: string; if (range === "tum") { const first = [...movements.values()] .flat() .map((m) => m.date) .sort()[0]; from = first && first < to ? first : addDays(to, -1); } else from = addMonths(to, -Number(range)); const price = await buyPriceLookup( ctx.m.householdId, [...new Set(docs.map((a) => a.assetKey))], to, ); const reports = docs.map((a) => periodReport(movements.get(String(a._id)) ?? [], (date) => price(a.assetKey, date), from, to), ); if (assetId) return { ...reports[0]!, excluded: 0 }; // Toplamda fiyatı eksik varlıklar (kâr hesaplanamayan) dışarıda bırakılır ve sayılır. const priced = reports.filter((r) => r.profitMinor !== null); return { ...sumReports(priced, from, to), excluded: reports.length - priced.length }; }