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:
@@ -3,10 +3,17 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { tr } from "@/i18n/tr";
|
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 { getHouseholdActionContext } from "@/lib/dal";
|
||||||
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
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);
|
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);
|
if (!id.success) return fail(tr.common.notFound);
|
||||||
return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data));
|
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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import mongoose, { Types } from "mongoose";
|
import mongoose, { Types } from "mongoose";
|
||||||
import { hasHouseholdRole } from "@/features/households/roles";
|
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 { loadContext, type Ctx } from "@/features/transactions/service";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
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 { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||||
import {
|
import {
|
||||||
holdingSummary,
|
holdingSummary,
|
||||||
|
periodReport,
|
||||||
runMovements,
|
runMovements,
|
||||||
|
sumReports,
|
||||||
type HoldingSummary,
|
type HoldingSummary,
|
||||||
type MovementKind,
|
type MovementKind,
|
||||||
|
type PeriodReport,
|
||||||
} from "@/shared/portfolio";
|
} from "@/shared/portfolio";
|
||||||
|
import { addDays, addMonths } from "@/shared/recurrence";
|
||||||
import { QUANTITY_SCALE } from "@/shared/quantity";
|
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||||
import type { AssetForm, MovementForm } from "@/shared/schemas/asset";
|
import type { AssetForm, MovementForm } from "@/shared/schemas/asset";
|
||||||
import { compareTr, foldTr } from "@/shared/search";
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
@@ -482,3 +486,58 @@ export async function deleteMovement(
|
|||||||
});
|
});
|
||||||
return ok(undefined);
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ import { toObjectId } from "@/lib/object-id";
|
|||||||
import { AssetPriceModel } from "@/models/asset-price";
|
import { AssetPriceModel } from "@/models/asset-price";
|
||||||
import { InKindDebtModel } from "@/models/in-kind-debt";
|
import { InKindDebtModel } from "@/models/in-kind-debt";
|
||||||
import { TransactionModel } from "@/models/transaction";
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { addMovement } from "@/features/assets/service";
|
||||||
|
import { AssetMovementModel } from "@/models/asset-movement";
|
||||||
|
import { priceOn, type PriceQuote } from "./prices";
|
||||||
import {
|
import {
|
||||||
ASSET_KEYS,
|
ASSET_KEYS,
|
||||||
formatAssetQuantity,
|
formatAssetQuantity,
|
||||||
@@ -38,19 +41,7 @@ const t = tr.inKind;
|
|||||||
|
|
||||||
// --- Fiyat tablosu ---
|
// --- Fiyat tablosu ---
|
||||||
|
|
||||||
export type PriceQuote = { buyMinor: number; sellMinor: number; date: string };
|
export { priceOn, type PriceQuote } from "./prices";
|
||||||
|
|
||||||
/** 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 type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
|
export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
|
||||||
|
|
||||||
@@ -381,6 +372,11 @@ export async function deleteInKind(
|
|||||||
{ _id: d._id },
|
{ _id: d._id },
|
||||||
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
{ $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({
|
await writeAudit({
|
||||||
action: "inkind.delete",
|
action: "inkind.delete",
|
||||||
actorId: actor,
|
actorId: actor,
|
||||||
@@ -491,7 +487,15 @@ export async function recordPayment(
|
|||||||
contactName,
|
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;
|
let transactionId: Types.ObjectId | null = null;
|
||||||
if (amountMinor > 0 && account) {
|
if (amountMinor > 0 && account) {
|
||||||
const out = d.direction === "debt";
|
const out = d.direction === "debt";
|
||||||
@@ -549,10 +553,50 @@ export async function recordPayment(
|
|||||||
},
|
},
|
||||||
session,
|
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 });
|
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). */
|
/** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */
|
||||||
export async function openInKindByContact(
|
export async function openInKindByContact(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 => ({
|
const buy = (quantity: number, amountMinor: number): MovementInput => ({
|
||||||
kind: "buy",
|
kind: "buy",
|
||||||
@@ -70,3 +79,67 @@ describe("ağırlıklı ortalama maliyet", () => {
|
|||||||
expect(formatBp(0)).toBe("%0");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { QUANTITY_SCALE } from "./quantity";
|
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
|
* 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 });
|
const s = (Math.abs(bp) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 2 });
|
||||||
return `${bp < 0 ? "−" : bp > 0 ? "+" : ""}%${s}`;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,9 +101,15 @@ export const paymentFormSchema = z
|
|||||||
quantity: z.preprocess(emptyToNull, quantity.nullable()),
|
quantity: z.preprocess(emptyToNull, quantity.nullable()),
|
||||||
amount: optionalMoney,
|
amount: optionalMoney,
|
||||||
accountId: z.preprocess(emptyToNull, objectId.nullable()),
|
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(""),
|
note: z.string().trim().max(300).default(""),
|
||||||
})
|
})
|
||||||
.superRefine((x, ctx) => {
|
.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)
|
if (x.method === "asset" && x.quantity === null)
|
||||||
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
|
ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity });
|
||||||
if (x.method === "cash") {
|
if (x.method === "cash") {
|
||||||
|
|||||||
Reference in New Issue
Block a user