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
+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") {