diff --git a/src/features/assets/actions.ts b/src/features/assets/actions.ts index 1a06208..1e0fe94 100644 --- a/src/features/assets/actions.ts +++ b/src/features/assets/actions.ts @@ -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> { + 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, + })), + ); +} diff --git a/src/features/assets/service.ts b/src/features/assets/service.ts index 2990e34..387503a 100644 --- a/src/features/assets/service.ts +++ b/src/features/assets/service.ts @@ -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() + ).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 }; +} diff --git a/src/features/in-kind/prices.ts b/src/features/in-kind/prices.ts new file mode 100644 index 0000000..27b8ac8 --- /dev/null +++ b/src/features/in-kind/prices.ts @@ -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 { + 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(); + 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; + }; +} diff --git a/src/features/in-kind/service.ts b/src/features/in-kind/service.ts index da82de0..e174c71 100644 --- a/src/features/in-kind/service.ts +++ b/src/features/in-kind/service.ts @@ -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 { - 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, diff --git a/src/features/net-worth/service.int.test.ts b/src/features/net-worth/service.int.test.ts new file mode 100644 index 0000000..40885c6 --- /dev/null +++ b/src/features/net-worth/service.int.test.ts @@ -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>; +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) => 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 }); + }); +}); diff --git a/src/features/net-worth/service.ts b/src/features/net-worth/service.ts new file mode 100644 index 0000000..d5f41dc --- /dev/null +++ b/src/features/net-worth/service.ts @@ -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 { + 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, + }; +} diff --git a/src/shared/portfolio.test.ts b/src/shared/portfolio.test.ts index 7451236..bb1d43e 100644 --- a/src/shared/portfolio.test.ts +++ b/src/shared/portfolio.test.ts @@ -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 = { + "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); + }); +}); diff --git a/src/shared/portfolio.ts b/src/shared/portfolio.ts index 3ca7c22..e22dfb9 100644 --- a/src/shared/portfolio.ts +++ b/src/shared/portfolio.ts @@ -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((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((a, r) => add(a, r.startValueMinor), 0); + const endValueMinor = reports.reduce((a, r) => add(a, r.endValueMinor), 0); + const profitMinor = reports.reduce((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, + }; +} diff --git a/src/shared/schemas/in-kind.ts b/src/shared/schemas/in-kind.ts index 64ac2e6..154796c 100644 --- a/src/shared/schemas/in-kind.ts +++ b/src/shared/schemas/in-kind.ts @@ -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") {