feat(assets): dönemsel rapor, net varlık ve aynî kayıt bağlantısı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 00:19:05 +03:00
parent 20e5a90df4
commit 9a60085d14
9 changed files with 806 additions and 19 deletions
+27 -2
View File
@@ -3,10 +3,17 @@
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, type ActionResult } from "@/lib/action-result";
import { fail, ok, 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";
import {
addMovement,
createAsset,
deleteAsset,
deleteMovement,
listAssets,
updateAsset,
} from "./service";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
@@ -60,3 +67,21 @@ export async function deleteMovementAction(movementId: unknown) {
if (!id.success) return fail(tr.common.notFound);
return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data));
}
export type AssetOption = { id: string; name: string; assetKey: string; quantity: number };
/** Aynî ödeme diyaloğu için görünür varlıklar (ad, tür, eldeki miktar). */
export async function getAssetOptionsAction(): Promise<ActionResult<AssetOption[]>> {
const ctx = await getHouseholdActionContext("viewer");
if (!ctx) return fail(tr.common.unauthorized);
const data = await listAssets(ctx.user.id, ctx.household.id);
if (!data) return fail(tr.common.unauthorized);
return ok(
data.items.map((a) => ({
id: a.id,
name: a.name,
assetKey: a.assetKey,
quantity: a.summary.quantity,
})),
);
}
+60 -1
View File
@@ -1,7 +1,7 @@
import "server-only";
import mongoose, { Types } from "mongoose";
import { hasHouseholdRole } from "@/features/households/roles";
import { priceOn, type PriceQuote } from "@/features/in-kind/service";
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";
@@ -14,10 +14,14 @@ 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";
@@ -482,3 +486,58 @@ export async function deleteMovement(
});
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<AssetDoc[]>()
).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 };
}
+60
View File
@@ -0,0 +1,60 @@
import "server-only";
import type { Types } from "mongoose";
import { AssetPriceModel } from "@/models/asset-price";
import type { AssetKey } from "@/shared/assets";
/** Fiyat tablosu okuma yardımcıları (aynî kayıtlar ve varlık takibi ortak kullanır). */
export type PriceQuote = { buyMinor: number; sellMinor: number; date: string };
/** Varlığın verilen tarihteki (veya öncesindeki en yakın) fiyatı. */
export async function priceOn(
householdId: Types.ObjectId,
assetKey: AssetKey,
date: string,
): Promise<PriceQuote | null> {
const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } })
.sort({ date: -1 })
.lean();
return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null;
}
/**
* Varlıkların `to` tarihine kadarki tüm fiyatları; dönen fonksiyon bir tarihteki (veya
* öncesindeki en yakın) alış fiyatını verir (yoksa null).
*/
export async function buyPriceLookup(
householdId: Types.ObjectId,
assetKeys: AssetKey[],
to: string,
): Promise<(assetKey: AssetKey, date: string) => number | null> {
const rows = await AssetPriceModel.find({
householdId,
assetKey: { $in: assetKeys },
date: { $lte: to },
})
.sort({ date: 1 })
.lean();
const by = new Map<string, { date: string; buyMinor: number }[]>();
for (const r of rows) {
const list = by.get(r.assetKey) ?? [];
list.push({ date: r.date, buyMinor: r.buyMinor });
by.set(r.assetKey, list);
}
return (assetKey, date) => {
const list = by.get(assetKey);
if (!list) return null;
// İkili arama: date'ten küçük-eşit son kayıt.
let lo = 0;
let hi = list.length - 1;
let found = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (list[mid]!.date <= date) {
found = mid;
lo = mid + 1;
} else hi = mid - 1;
}
return found === -1 ? null : list[found]!.buyMinor;
};
}
+59 -15
View File
@@ -9,6 +9,9 @@ import { toObjectId } from "@/lib/object-id";
import { AssetPriceModel } from "@/models/asset-price";
import { InKindDebtModel } from "@/models/in-kind-debt";
import { TransactionModel } from "@/models/transaction";
import { addMovement } from "@/features/assets/service";
import { AssetMovementModel } from "@/models/asset-movement";
import { priceOn, type PriceQuote } from "./prices";
import {
ASSET_KEYS,
formatAssetQuantity,
@@ -38,19 +41,7 @@ const t = tr.inKind;
// --- Fiyat tablosu ---
export type PriceQuote = { buyMinor: number; sellMinor: number; date: string };
/** Varlığın verilen tarihteki (veya öncesindeki en yakın) fiyatı. */
export async function priceOn(
householdId: Types.ObjectId,
assetKey: AssetKey,
date: string,
): Promise<PriceQuote | null> {
const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } })
.sort({ date: -1 })
.lean();
return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null;
}
export { priceOn, type PriceQuote } from "./prices";
export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
@@ -381,6 +372,11 @@ export async function deleteInKind(
{ _id: d._id },
{ $set: { deletedAt: new Date(), updatedBy: actor } },
);
// Bağlı varlık hareketleri serbest kalır (Birikim sayfasından yönetilebilir).
await AssetMovementModel.updateMany(
{ householdId: ctx.m.householdId, inKindDebtId: d._id },
{ $set: { inKindDebtId: null } },
);
await writeAudit({
action: "inkind.delete",
actorId: actor,
@@ -491,7 +487,15 @@ export async function recordPayment(
contactName,
);
await mongoose.connection.transaction(async (session) => {
if (input.assetId) {
const asset = ctx.assetMap.get(input.assetId);
if (!asset || asset.hidden || asset.deleted)
return fail(tr.common.notFound, { assetId: tr.common.notFound });
if (asset.assetKey !== d.assetKey)
return fail(t.link.assetMismatch, { assetId: t.link.assetMismatch });
}
const writePayment = async (session: mongoose.ClientSession) => {
let transactionId: Types.ObjectId | null = null;
if (amountMinor > 0 && account) {
const out = d.direction === "debt";
@@ -549,10 +553,50 @@ export async function recordPayment(
},
session,
);
});
};
try {
await mongoose.connection.transaction(async (session) => {
// Varlık bağlantısı: borç ödemesi varlıktan çıkış, alacak tahsilatı varlığa giriş.
if (input.assetId) {
const r = await addMovement(
actorId,
householdId,
input.assetId,
{
kind: d.direction === "debt" ? "gift_out" : "gift_in",
date: input.date,
quantity,
unitPrice: d.direction === "debt" ? null : (d.unitPriceMinor ?? null),
amount: null,
accountId: null,
storage: "",
note: t.link.movementNote(contactName),
},
{ inKindDebtId: d._id, session },
);
if (!r.ok) throw new LinkError(r.error, r.fieldErrors?.quantity);
}
await writePayment(session);
});
} catch (error) {
if (error instanceof LinkError)
return fail(error.message, error.quantity ? { quantity: error.quantity } : undefined);
throw error;
}
return ok({ closed });
}
/** Varlık hareketi eklenemedi: transaction geri alınır, hata kullanıcıya döner. */
class LinkError extends Error {
constructor(
message: string,
readonly quantity?: string,
) {
super(message);
}
}
/** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */
export async function openInKindByContact(
userId: string,
+310
View File
@@ -0,0 +1,310 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { addMovement, createAsset, getAsset, getPeriodReport } from "@/features/assets/service";
import { createContactEntry, createContact } from "@/features/contacts/service";
import {
createInKind,
deleteInKind,
listInKind,
recordPayment,
savePrice,
} from "@/features/in-kind/service";
import { AccountModel } from "@/models/account";
import { AssetModel } from "@/models/asset";
import { AssetMovementModel } from "@/models/asset-movement";
import { AssetPriceModel } from "@/models/asset-price";
import { InKindDebtModel } from "@/models/in-kind-debt";
import { TransactionModel } from "@/models/transaction";
import { todayIstanbul } from "@/shared/dates";
import { addDays, addMonths } from "@/shared/recurrence";
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
import { contactEntrySchema, contactFormSchema } from "@/shared/schemas/contact";
import {
assetPriceFormSchema,
inKindFormSchema,
paymentFormSchema,
} from "@/shared/schemas/in-kind";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import { getNetWorth } from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let emre = "";
const today = todayIstanbul();
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
emre = ids.user("emre");
// Seed varlık/fiyat/aynî örnekleri senaryoları etkilemesin.
await Promise.all([
AssetPriceModel.deleteMany({}),
AssetModel.deleteMany({}),
AssetMovementModel.deleteMany({}),
InKindDebtModel.deleteMany({}),
TransactionModel.deleteMany({
$or: [{ assetId: { $ne: null } }, { inKindDebtId: { $ne: null } }],
}),
]);
});
afterAll(teardownDb);
const acc = (name: string) =>
AccountModel.findOne({ householdId: cakir, name })
.lean()
.then((a) => String(a!._id));
const price = (assetKey: string, date: string, buy: string, sell = "") =>
savePrice(ayse, cakir, assetPriceFormSchema.parse({ assetKey, date, buy, sell }));
const mv = (input: Record<string, unknown>) => movementFormSchema.parse({ date: today, ...input });
async function asset(name: string, assetKey = "gram_gold", isPrivate = false, by = ayse) {
const r = await createAsset(by, cakir, assetFormSchema.parse({ assetKey, name, isPrivate }));
return r.ok ? r.data.id : "";
}
async function contact(name: string) {
const r = await createContact(
ayse,
cakir,
contactFormSchema.parse({ name, openingDate: addDays(today, -200) }),
);
return r.ok ? r.data.id : "";
}
const netWorth = async (user = ayse) => (await getNetWorth(user, cakir))!;
describe("dönemsel rapor", () => {
let gold = "";
it("son 6 ay: yatırılan, dönem sonu değer ve kâr; aylık noktalar", async () => {
const m6 = addMonths(today, -6);
await price("gram_gold", addDays(m6, -10), "2.500", "2.550");
await price("gram_gold", addMonths(today, -3), "2.800", "2.850");
await price("gram_gold", today, "3.000", "3.050");
gold = await asset("Rapor altın");
// Dönem öncesi 2 gr (başlangıç değeri), dönem içinde 10 gr + 1 gr.
await addMovement(
ayse,
cakir,
gold,
mv({ kind: "buy", date: addDays(m6, -5), quantity: "2", amount: "5.000" }),
);
await addMovement(
ayse,
cakir,
gold,
mv({ kind: "buy", date: addDays(m6, 3), quantity: "10", amount: "25.000" }),
);
await addMovement(
ayse,
cakir,
gold,
mv({ kind: "buy", date: addMonths(today, -2), quantity: "1", amount: "2.850" }),
);
const r = (await getPeriodReport(ayse, cakir, "6", gold))!;
expect(r.from).toBe(m6);
expect(r.startValueMinor).toBe(5_000_00);
expect(r.investedMinor).toBe(27_850_00);
expect(r.endValueMinor).toBe(39_000_00);
expect(r.profitMinor).toBe(39_000_00 - 5_000_00 - 27_850_00);
expect(r.points[0]!.date).toBe(m6);
expect(r.points.at(-1)).toMatchObject({
date: today,
valueMinor: 39_000_00,
costMinor: 32_850_00,
});
expect(r.points.length).toBeGreaterThanOrEqual(7);
// "Tümü" ilk hareketten başlar; başlangıç değeri 0, kâr = değer − maliyet.
const all = (await getPeriodReport(ayse, cakir, "tum", gold))!;
expect(all.startValueMinor).toBe(0);
expect(all.profitMinor).toBe(39_000_00 - 32_850_00);
});
it("toplam rapor gizli varlığı başkasına katmaz; fiyatsız varlık dışarıda sayılır", async () => {
const secret = await asset("Gizli altın", "gram_gold", true, emre);
await addMovement(emre, cakir, secret, mv({ kind: "buy", quantity: "5", amount: "15.000" }));
const silver = await asset("Gümüş", "silver_gram");
await addMovement(
ayse,
cakir,
silver,
mv({ kind: "gift_in", quantity: "100", amount: "4.000" }),
);
const forAyse = (await getPeriodReport(ayse, cakir, "tum"))!;
expect(forAyse.investedMinor).toBe(32_850_00);
expect(forAyse.excluded).toBe(1);
const forEmre = (await getPeriodReport(emre, cakir, "tum"))!;
expect(forEmre.investedMinor).toBe(32_850_00 + 15_000_00);
expect(await getPeriodReport(ayse, cakir, "6", secret)).toBeNull();
});
});
describe("net varlık", () => {
it("formül: hesaplar − kart borcu + varlıklar + alacaklar − borçlar", async () => {
const n = await netWorth();
expect(n.totalMinor).toBe(
n.accountsMinor -
n.cardDebtMinor +
n.assetsMinor +
n.contactReceivableMinor -
n.contactPayableMinor +
n.inKindReceivableMinor -
n.inKindPayableMinor,
);
// Gümüşün fiyatı yok → katılmaz ve sayılır.
expect(n.missingPrice).toBe(1);
// Emre kendi gizli altınını da görür: 5 × 3.000.
expect((await netWorth(emre)).assetsMinor - n.assetsMinor).toBe(15_000_00);
});
it("satış fiyatından alım net varlığı yalnızca makas kadar düşürür; cariden borç almak değiştirmez", async () => {
const gold = await asset("Makas altın");
const n0 = (await netWorth()).totalMinor;
await addMovement(
ayse,
cakir,
gold,
mv({ kind: "buy", quantity: "1", accountId: await acc("Ziraat Maaş") }),
);
expect((await netWorth()).totalMinor).toBe(n0 - 50_00);
const ali = await contact("Net Ali");
const n1 = (await netWorth()).totalMinor;
await createContactEntry(
ayse,
cakir,
ali,
contactEntrySchema.parse({
kind: "borrow",
accountId: await acc("Cüzdan"),
amount: "1.000",
date: today,
}),
);
const n2 = await netWorth();
expect(n2.totalMinor).toBe(n1);
expect(n2.contactPayableMinor).toBeGreaterThanOrEqual(1_000_00);
});
it("aynî borç satış, aynî alacak alış fiyatıyla düşülür/eklenir", async () => {
const hasan = await contact("Net Hasan");
const n0 = (await netWorth()).totalMinor;
await createInKind(
ayse,
cakir,
hasan,
inKindFormSchema.parse({
direction: "debt",
assetKey: "gram_gold",
quantity: "2",
date: today,
}),
);
expect((await netWorth()).totalMinor).toBe(n0 - 6_100_00);
await createInKind(
ayse,
cakir,
hasan,
inKindFormSchema.parse({
direction: "receivable",
assetKey: "gram_gold",
quantity: "1",
date: today,
}),
);
expect((await netWorth()).totalMinor).toBe(n0 - 6_100_00 + 3_000_00);
});
});
describe("aynî kayıt ↔ varlık bağlantısı", () => {
it("borç aynı varlıkla ödenirken varlıktan düşer; yetersizse hiçbir şey yazılmaz", async () => {
const kasa = await asset("Kasa altın");
await addMovement(ayse, cakir, kasa, mv({ kind: "buy", quantity: "3", amount: "9.000" }));
const veli = await contact("Bağ Veli");
const debt = await createInKind(
ayse,
cakir,
veli,
inKindFormSchema.parse({
direction: "debt",
assetKey: "gram_gold",
quantity: "5",
date: today,
}),
);
const debtId = debt.ok ? debt.data.id : "";
// 4 gr ödemek istiyor ama kasada 3 gr var → reddedilir, ödeme yazılmaz.
const fail = await recordPayment(
ayse,
cakir,
debtId,
paymentFormSchema.parse({ method: "asset", date: today, quantity: "4", assetId: kasa }),
);
expect(fail.ok).toBe(false);
expect((await InKindDebtModel.findById(debtId).lean())!.payments).toHaveLength(0);
const r = await recordPayment(
ayse,
cakir,
debtId,
paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: kasa }),
);
expect(r).toMatchObject({ ok: true, data: { closed: false } });
const a = (await getAsset(ayse, cakir, kasa))!;
expect(a.summary.quantity).toBe(1_000);
expect(a.movements[0]).toMatchObject({ kind: "gift_out", quantity: 2_000, fromInKind: true });
expect((await listInKind(ayse, cakir, veli))![0]!.paidQuantity).toBe(2_000);
// Kayıt silinince hareket serbest kalır.
await deleteInKind(ayse, cakir, debtId);
expect((await getAsset(ayse, cakir, kasa))!.movements[0]!.fromInKind).toBe(false);
});
it("alacak aynı varlıkla tahsil edilince varlığa borç tarihindeki fiyatla eklenir", async () => {
const ziynet = await asset("Ziynet kutusu", "quarter_gold");
const ayten = await contact("Bağ Ayten");
const rec = await createInKind(
ayse,
cakir,
ayten,
inKindFormSchema.parse({
direction: "receivable",
assetKey: "quarter_gold",
quantity: "2",
date: addDays(today, -30),
unitPrice: "5.000",
}),
);
const recId = rec.ok ? rec.data.id : "";
const gold = await asset("Yanlış tür");
expect(
(
await recordPayment(
ayse,
cakir,
recId,
paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: gold }),
)
).ok,
).toBe(false);
expect(
paymentFormSchema.safeParse({
method: "cash",
date: today,
amount: "1",
accountId: await acc("Cüzdan"),
assetId: ziynet,
}).success,
).toBe(false);
const r = await recordPayment(
ayse,
cakir,
recId,
paymentFormSchema.parse({ method: "asset", date: today, quantity: "2", assetId: ziynet }),
);
expect(r).toMatchObject({ ok: true, data: { closed: true } });
const a = (await getAsset(ayse, cakir, ziynet))!;
expect(a.summary).toMatchObject({ quantity: 2_000, costMinor: 10_000_00, investedMinor: 0 });
expect(a.movements[0]).toMatchObject({ kind: "gift_in", fromInKind: true });
});
});
+71
View File
@@ -0,0 +1,71 @@
import "server-only";
import { listAccounts } from "@/features/accounts/service";
import { listAssets } from "@/features/assets/service";
import { listContacts } from "@/features/contacts/service";
import { listInKind } from "@/features/in-kind/service";
/**
* Net varlık (Bölüm 6.6): hesap bakiyeleri (kart borçları eksi) + varlıkların güncel değeri
* (alış) + cari alacaklar − cari borçlar + aynî alacaklar (alış) − aynî borçlar (satış).
* Görünürlük kaynak listelerin kurallarını izler (gizli varlık/cari sayılmaz; gizli hesap
* bakiyeleri hane ayarına göre toplama girer).
*/
export type NetWorth = {
accountsMinor: number;
cardDebtMinor: number;
assetsMinor: number;
contactReceivableMinor: number;
contactPayableMinor: number;
inKindReceivableMinor: number;
inKindPayableMinor: number;
totalMinor: number;
/** Fiyatı olmadığı için katılmayan varlık + aynî kayıt sayısı. */
missingPrice: number;
/** Başkasının gizli hesabının bakiyesi toplama dahil (hane ayarı). */
includesHidden: boolean;
};
export async function getNetWorth(userId: string, householdId: string): Promise<NetWorth | null> {
const [accounts, assets, contacts, inKind] = await Promise.all([
listAccounts(userId, householdId),
listAssets(userId, householdId),
listContacts(userId, householdId),
listInKind(userId, householdId),
]);
if (!accounts || !assets || !contacts || !inKind) return null;
let inKindReceivableMinor = 0;
let inKindPayableMinor = 0;
let missingPrice = assets.totals.missingPrice;
for (const v of inKind) {
if (v.closedAt || v.progress.remainingQuantity === 0) continue;
const value = v.progress.currentValueMinor;
if (value === null) {
missingPrice++;
continue;
}
if (v.direction === "debt") inKindPayableMinor += value;
else inKindReceivableMinor += value;
}
const r = {
accountsMinor: accounts.totals.assetsMinor,
cardDebtMinor: -accounts.totals.cardDebtMinor,
assetsMinor: assets.totals.valueMinor,
contactReceivableMinor: contacts.totals.owesMeMinor,
contactPayableMinor: contacts.totals.iOweMinor,
inKindReceivableMinor,
inKindPayableMinor,
};
return {
...r,
totalMinor:
r.accountsMinor -
r.cardDebtMinor +
r.assetsMinor +
r.contactReceivableMinor -
r.contactPayableMinor +
r.inKindReceivableMinor -
r.inKindPayableMinor,
missingPrice,
includesHidden: accounts.totals.includesHidden,
};
}
+74 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from "vitest";
import { formatBp, holdingSummary, runMovements, type MovementInput } from "./portfolio";
import {
formatBp,
holdingSummary,
periodPoints,
periodReport,
runMovements,
sumReports,
type DatedMovement,
type MovementInput,
} from "./portfolio";
const buy = (quantity: number, amountMinor: number): MovementInput => ({
kind: "buy",
@@ -70,3 +79,67 @@ describe("ağırlıklı ortalama maliyet", () => {
expect(formatBp(0)).toBe("%0");
});
});
describe("dönemsel rapor", () => {
it("noktalar: başlangıç, ay sonları, bitiş", () => {
expect(periodPoints("2026-05-06", "2026-08-15")).toEqual([
"2026-05-06",
"2026-05-31",
"2026-06-30",
"2026-07-31",
"2026-08-15",
]);
expect(periodPoints("2026-05-31", "2026-06-30")).toEqual(["2026-05-31", "2026-06-30"]);
});
// Örnek: 10 gr'ı 25.000'e aldım, her ay 1 gr ekledim; 6 ay sonra ne kazandım?
const prices: Record<string, number> = {
"2026-01-01": 2_500_00,
"2026-02-01": 2_600_00,
"2026-03-01": 2_700_00,
"2026-07-01": 3_000_00,
};
const priceAt = (date: string) => {
const keys = Object.keys(prices)
.filter((k) => k <= date)
.sort();
return keys.length ? prices[keys.at(-1)!]! : null;
};
const ms: DatedMovement[] = [
{ kind: "buy", date: "2026-01-01", quantity: 10_000, amountMinor: 25_000_00 },
{ kind: "buy", date: "2026-02-01", quantity: 1_000, amountMinor: 2_600_00 },
{ kind: "buy", date: "2026-03-01", quantity: 1_000, amountMinor: 2_700_00 },
{ kind: "gift_out", date: "2026-07-01", quantity: 1_000, amountMinor: 0 },
];
it("örnek senaryo: yatırılan, dönem sonu değer, kâr", () => {
const r = periodReport(ms, priceAt, "2026-01-01", "2026-07-02");
expect(r.startValueMinor).toBe(0);
expect(r.investedMinor).toBe(30_300_00);
// 11 gr × 3.000 = 33.000; çıkan 1 gr o günkü değeri 3.000
expect(r.endValueMinor).toBe(33_000_00);
expect(r.giftOutMinor).toBe(3_000_00);
expect(r.profitMinor).toBe(5_700_00);
expect(r.profitBp).toBe(1881);
expect(r.points.map((p) => p.valueMinor)).toEqual([
0, 25_000_00, 28_600_00, 32_400_00, 32_400_00, 32_400_00, 32_400_00, 33_000_00,
]);
});
it("başlangıçta eldeki değer dönem içi kârdan düşülür; fiyat yoksa null", () => {
const r = periodReport(ms, priceAt, "2026-03-15", "2026-07-02");
expect(r.startValueMinor).toBe(32_400_00);
expect(r.investedMinor).toBe(0);
expect(r.profitMinor).toBe(33_000_00 + 3_000_00 - 32_400_00);
expect(periodReport(ms, () => null, "2026-01-01", "2026-07-02").profitMinor).toBeNull();
});
it("toplam", () => {
const a = periodReport(ms, priceAt, "2026-01-01", "2026-07-02");
const t = sumReports([a, a], "2026-01-01", "2026-07-02");
expect(t.investedMinor).toBe(60_600_00);
expect(t.profitMinor).toBe(11_400_00);
expect(t.points.at(-1)!.valueMinor).toBe(66_000_00);
expect(sumReports([], "2026-01-01", "2026-01-31").points).toHaveLength(2);
});
});
+139
View File
@@ -1,4 +1,5 @@
import { QUANTITY_SCALE } from "./quantity";
import { addDays, addMonths } from "./recurrence";
/**
* Varlık takibi hesap motoru (Bölüm 6.6): ağırlıklı ortalama maliyet, gerçekleşmiş ve
@@ -144,3 +145,141 @@ export function formatBp(bp: number): string {
const s = (Math.abs(bp) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 2 });
return `${bp < 0 ? "−" : bp > 0 ? "+" : ""}%${s}`;
}
// --- Dönemsel rapor (Bölüm 6.6) ---
export type DatedMovement = MovementInput & { date: string };
/** Dönem noktaları: başlangıç, aradaki ay sonları ve bitiş ("YYYY-MM-DD", artan, tekil). */
export function periodPoints(from: string, to: string): string[] {
const out = [from];
let monthEnd = addMonths(`${from.slice(0, 7)}-01`, 0, "last");
while (monthEnd < to) {
if (monthEnd > from) out.push(monthEnd);
monthEnd = addMonths(addDays(monthEnd, 1), 0, "last");
}
if (to > from) out.push(to);
return out;
}
export type PeriodPoint = { date: string; valueMinor: number | null; costMinor: number };
export type PeriodReport = {
from: string;
to: string;
/** Dönem başındaki (başlangıç gününden önceki hareketlerle) değer. */
startValueMinor: number | null;
endValueMinor: number | null;
/** Dönemdeki alımlarda ödenen. */
investedMinor: number;
/** Dönemdeki satışlardan alınan. */
proceedsMinor: number;
/** Dönemde gelen hediyelerin girişteki değeri. */
giftInMinor: number;
/** Dönemde çıkan hediyelerin o günkü değeri (fiyat yoksa maliyeti). */
giftOutMinor: number;
/**
* Dönem kârı: bitiş değeri + satış + çıkan hediye − başlangıç değeri − alım − gelen hediye.
* Fiyat eksikse null.
*/
profitMinor: number | null;
/** Kârın, dönemde bağlanan sermayeye (başlangıç + alım + gelen hediye) oranı (baz puan). */
profitBp: number | null;
points: PeriodPoint[];
};
/**
* Bir varlığın dönem raporu. `movements` kronolojik sıralı; `priceAt(date)` o tarihteki (veya
* öncesindeki en yakın) değerleme fiyatı (alış), yoksa null.
*/
export function periodReport(
movements: DatedMovement[],
priceAt: (date: string) => number | null,
from: string,
to: string,
): PeriodReport {
const valueAt = (date: string, inclusive: boolean) => {
const run = runMovements(movements.filter((m) => (inclusive ? m.date <= date : m.date < date)));
const price = priceAt(date);
return {
run,
value:
run.quantity === 0
? 0
: price === null
? null
: Math.round((run.quantity * price) / QUANTITY_SCALE),
};
};
const start = valueAt(from, false);
const end = valueAt(to, true);
const all = runMovements(movements);
let invested = 0;
let proceeds = 0;
let giftIn = 0;
let giftOut = 0;
movements.forEach((m, i) => {
if (m.date < from || m.date > to) return;
if (m.kind === "buy") invested += m.amountMinor;
else if (m.kind === "sell") proceeds += m.amountMinor;
else if (m.kind === "gift_in") giftIn += m.amountMinor;
else {
const price = priceAt(m.date);
giftOut +=
price === null
? all.steps[i]!.costOutMinor
: Math.round((m.quantity * price) / QUANTITY_SCALE);
}
});
const profitMinor =
start.value === null || end.value === null
? null
: end.value + proceeds + giftOut - start.value - invested - giftIn;
const base = (start.value ?? 0) + invested + giftIn;
const points = periodPoints(from, to).map((date, i) => {
const p = i === 0 ? start : valueAt(date, true);
return { date, valueMinor: p.value, costMinor: p.run.costMinor };
});
return {
from,
to,
startValueMinor: start.value,
endValueMinor: end.value,
investedMinor: invested,
proceedsMinor: proceeds,
giftInMinor: giftIn,
giftOutMinor: giftOut,
profitMinor,
profitBp: profitMinor === null || base <= 0 ? null : Math.round((profitMinor * 10_000) / base),
points,
};
}
/** Birden çok varlığın raporlarını toplar (noktalar aynı tarihlerde). Eksik fiyatlı değerler atlanır. */
export function sumReports(reports: PeriodReport[], from: string, to: string): PeriodReport {
const add = (a: number | null, b: number | null) => (a === null || b === null ? null : a + b);
const points = periodPoints(from, to).map((date, i) => ({
date,
valueMinor: reports.reduce<number | null>((a, r) => add(a, r.points[i]?.valueMinor ?? null), 0),
costMinor: reports.reduce((a, r) => a + (r.points[i]?.costMinor ?? 0), 0),
}));
const sum = (k: "investedMinor" | "proceedsMinor" | "giftInMinor" | "giftOutMinor") =>
reports.reduce((a, r) => a + r[k], 0);
const startValueMinor = reports.reduce<number | null>((a, r) => add(a, r.startValueMinor), 0);
const endValueMinor = reports.reduce<number | null>((a, r) => add(a, r.endValueMinor), 0);
const profitMinor = reports.reduce<number | null>((a, r) => add(a, r.profitMinor), 0);
const base = (startValueMinor ?? 0) + sum("investedMinor") + sum("giftInMinor");
return {
from,
to,
startValueMinor,
endValueMinor,
investedMinor: sum("investedMinor"),
proceedsMinor: sum("proceedsMinor"),
giftInMinor: sum("giftInMinor"),
giftOutMinor: sum("giftOutMinor"),
profitMinor,
profitBp: profitMinor === null || base <= 0 ? null : Math.round((profitMinor * 10_000) / base),
points,
};
}
+6
View File
@@ -101,9 +101,15 @@ export const paymentFormSchema = z
quantity: z.preprocess(emptyToNull, quantity.nullable()),
amount: optionalMoney,
accountId: z.preprocess(emptyToNull, objectId.nullable()),
/** Aynı varlıkla ödemede bağlanacak varlık (Faz 9b): borçta varlıktan düşer, alacakta eklenir. */
assetId: z.preprocess(emptyToNull, objectId.nullable()).default(null),
note: z.string().trim().max(300).default(""),
})
.superRefine((x, ctx) => {
if (x.assetId && x.method !== "asset")
ctx.addIssue({ code: "custom", path: ["assetId"], message: k.assetOnlyWithAsset });
if (x.assetId && x.amount)
ctx.addIssue({ code: "custom", path: ["amount"], message: k.noAmountWithAsset });
if (x.method === "asset" && x.quantity === null)
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
if (x.method === "cash") {