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:
2026-11-05 23:17:44 +03:00
parent baec70d324
commit 890ba9f521
14 changed files with 1415 additions and 3 deletions
+484
View File
@@ -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);
}