feat: Faz 9a - varlık lotları ve kâr/zarar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 23:17:44 +03:00
26 changed files with 2475 additions and 7 deletions
+12
View File
@@ -6,6 +6,18 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
## [Yayınlanmamış]
### Eklendi — Faz 9a: varlık lotları ve kâr/zarar
- Birikim sayfası (`/birikim`): altın, döviz ve ziynet varlıkları (`assets`); toplam güncel değer, toplam maliyet, gerçekleşmemiş ve gerçekleşmiş kâr/zarar; elde kalmayan varlıklar ayrı grupta. Fiyatı olmayan varlık toplam değere katılmaz ve belirtilir.
- Varlık hareketleri (`assetMovements`): alım, gelen hediye/miras, satış, hediye/çıkış; tarih, miktar, tutar veya birim fiyat (ikisi de boşsa fiyat tablosundan: alımda satış, satış ve hediyede alış fiyatı), hesap, saklama yeri, not. Geçmiş tarihli satış/çıkış o tarihteki miktarla sınırlıdır.
- Hesap bağlantısı: alım hesaptan varlığa, satış varlıktan hesaba transfer olarak kaydedilir (bakiye değişir, gelir/gider raporlarına girmez). İşlem listesinde varlık adıyla görünür; bağlı işlem tek başına düzenlenemez/silinemez, hareket silinince birlikte silinir.
- Hesap motoru (`shared/portfolio.ts`): ağırlıklı ortalama maliyet, satışta ortalama maliyetle gerçekleşmiş K/Z, çıkışta K/Z yazılmaz, gelen hediyenin maliyeti o günkü değer (yatırım sayılmaz); güncel değer alış fiyatıyla (Faz 0), gerçekleşmemiş K/Z TL ve %.
- `/birikim/:id`: özet (eldeki miktar, ortalama maliyet, maliyet, güncel değer, K/Z, yatırılan, satış geliri), hareket geçmişi (kalan miktar, satış K/Z'si), hareket silme (sonraki çıkışı eksiye düşürecekse reddedilir).
- "Yalnızca ben" varlık: varlık, hareketleri ve bağlı hesap işlemleri yalnızca sahibine görünür.
- Seed: aylık alımlı ve bir satışlı gram altın birikimi, düğün ziynetleri (hediye giriş ve çıkış), Emre'nin "yalnızca ben" dolar varlığı, Annemler'de fiyatsız bilezikler.
- `009` migration'ı: `assets`, `assetMovements` index'leri, işlemlerde `assetId`. Denetim kaydı: `asset.delete`, `asset.movement.delete`.
- Testler: ortalama maliyet, satış/çıkış, hediye, eksi miktar, oran biçimi (birim); alım/satış transferleri ve bakiye, fiyat tablosundan tutar, gerçekleşmiş K/Z, kilitli işlem, hareket silme, gizli varlık, izleyici ve başka hane (entegrasyon).
## [0.8.0] - 2026-11-05
Faz 8: cari hesaplar ve aynî borç/alacak (8a ve 8b alt fazları).
+15
View File
@@ -0,0 +1,15 @@
import { AssetModel } from "@/models/asset";
import { AssetMovementModel } from "@/models/asset-movement";
import type { Migration } from "./types";
export const migration: Migration = {
name: "009-assets",
description: "assets ve assetMovements index'leri; işlemlerde varlık alanları",
async up(db) {
await db
.collection("transactions")
.updateMany({ assetId: { $exists: false } }, { $set: { assetId: null } });
await AssetModel.createIndexes();
await AssetMovementModel.createIndexes();
},
};
+2 -1
View File
@@ -8,10 +8,11 @@ import { migration as m005 } from "./005-recurring";
import { migration as m006 } from "./006-card-statements";
import { migration as m007 } from "./007-contacts";
import { migration as m008 } from "./008-in-kind";
import { migration as m009 } from "./009-assets";
import type { Migration } from "./types";
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008];
export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008, m009];
const COLLECTION = "migrations";
+162
View File
@@ -0,0 +1,162 @@
import type { Types } from "mongoose";
import { addMovement, createAsset } from "@/features/assets/service";
import { AccountModel } from "@/models/account";
import { AssetModel } from "@/models/asset";
import { AssetMovementModel } from "@/models/asset-movement";
import { TransactionModel } from "@/models/transaction";
import type { AssetKey } from "@/shared/assets";
import { todayIstanbul } from "@/shared/dates";
import type { MovementKind } from "@/shared/portfolio";
import { addDays } from "@/shared/recurrence";
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
/**
* Örnek varlıklar (Faz 9a). Tutarı boş alımlar fiyat tablosundaki örnek fiyatlardan (seedInKind
* önce çalışır) hesaplanır. İdempotent: varlık `seedRef` ile bir kez oluşturulur.
*/
type SeedMovement = {
kind: MovementKind;
daysAgo: number;
quantity: string;
amount?: string;
account?: string;
storage?: string;
note?: string;
};
type SeedAsset = {
ref: string;
household: string;
by: string;
assetKey: AssetKey;
name: string;
isPrivate?: boolean;
note?: string;
movements: SeedMovement[];
};
const monthly = (from: number, to: number): SeedMovement[] =>
Array.from({ length: to - from + 1 }, (_, i) => ({
kind: "buy" as const,
daysAgo: (to - i) * 30 + 2,
quantity: "1",
account: "Ziraat Maaş",
storage: "Ev kasası",
}));
const ITEMS: SeedAsset[] = [
{
ref: "gram-altin",
household: "Çakır Ailesi",
by: "ayse",
assetKey: "gram_gold",
name: "Gram altın birikimi",
note: "Her ay maaştan 1 gr",
movements: [
{
kind: "buy",
daysAgo: 178,
quantity: "4",
account: "Birikim (Vadeli)",
storage: "Ev kasası",
note: "İlk alım",
},
...monthly(1, 5),
{ kind: "sell", daysAgo: 12, quantity: "2", account: "Birikim (Vadeli)", note: "Tatil için" },
],
},
{
ref: "ziynet",
household: "Çakır Ailesi",
by: "admin",
assetKey: "quarter_gold",
name: "Düğün ziynetleri",
movements: [
{
kind: "gift_in",
daysAgo: 175,
quantity: "6",
storage: "Banka kiralık kasa",
note: "Düğün takıları",
},
{ kind: "gift_out", daysAgo: 40, quantity: "1", note: "Yeğenin düğünü" },
],
},
{
ref: "emre-dolar",
household: "Çakır Ailesi",
by: "emre",
assetKey: "usd",
name: "Dolar",
isPrivate: true,
movements: [
{ kind: "buy", daysAgo: 120, quantity: "200", account: "Ziraat Maaş", storage: "Cüzdan" },
],
},
{
ref: "bilezik",
household: "Annemler",
by: "fatma",
assetKey: "bracelet_22k",
name: "Bilezikler",
note: "Fiyat tablosunda bilezik fiyatı yok (örnek: fiyatsız varlık)",
movements: [{ kind: "gift_in", daysAgo: 160, quantity: "40", amount: "120.000" }],
},
];
export async function seedAssets(
householdIds: Map<string, Types.ObjectId>,
userIds: Map<string, string>,
): Promise<number> {
const today = todayIstanbul();
let created = 0;
for (const x of ITEMS) {
const householdId = householdIds.get(x.household)!;
const seedRef = `asset:${x.ref}`;
if (await AssetModel.exists({ householdId, seedRef })) continue;
const by = userIds.get(x.by)!;
const hh = String(householdId);
const r = await createAsset(
by,
hh,
assetFormSchema.parse({
assetKey: x.assetKey,
name: x.name,
note: x.note ?? "",
isPrivate: x.isPrivate ?? false,
}),
);
if (!r.ok) throw new Error(`Seed varlığı oluşturulamadı (${x.ref}): ${r.error}`);
await AssetModel.updateOne({ _id: r.data.id }, { $set: { seedRef } });
for (const [i, m] of x.movements.entries()) {
const account = m.account
? String((await AccountModel.findOne({ householdId, name: m.account }).lean())!._id)
: "";
const res = await addMovement(
by,
hh,
r.data.id,
movementFormSchema.parse({
kind: m.kind,
date: addDays(today, -m.daysAgo),
quantity: m.quantity,
amount: m.amount ?? "",
accountId: account,
storage: m.storage ?? "",
note: m.note ?? "",
}),
);
if (!res.ok) throw new Error(`Seed varlık hareketi eklenemedi (${x.ref}): ${res.error}`);
const ref = `${seedRef}:${i}`;
const mv = await AssetMovementModel.findOneAndUpdate(
{ _id: res.data.id },
{ $set: { seedRef: ref } },
).lean();
if (mv?.transactionId)
await TransactionModel.updateOne({ _id: mv.transactionId }, { $set: { seedRef: ref } });
}
created++;
}
return created;
}
+2
View File
@@ -32,6 +32,7 @@ describe("seed", () => {
expect(summary.created.categories).toBeGreaterThan(40);
expect(summary.created.accounts).toBe(7);
expect(summary.created.transactions).toBeGreaterThan(150);
expect(summary.created.assets).toBe(4);
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdMemberModel.countDocuments()).toBe(memberCount);
});
@@ -49,6 +50,7 @@ describe("seed", () => {
installmentPlans: 0,
contacts: 0,
inKind: 0,
assets: 0,
});
expect(await getDb().collection("user").countDocuments()).toBe(SEED_USERS.length + 1);
expect(await HouseholdModel.countDocuments()).toBe(SEED_HOUSEHOLDS.length);
+7
View File
@@ -11,6 +11,7 @@ import { HouseholdMemberModel } from "@/models/household-member";
import { storedEmail } from "@/shared/account-email";
import { runMigrations } from "../migrations";
import { seedContacts } from "./contacts";
import { seedAssets } from "./assets";
import { seedInKind } from "./in-kind";
import { seedInstallments } from "./installments";
import { seedRecurring } from "./recurring";
@@ -51,6 +52,7 @@ export type SeedSummary = {
installmentPlans: number;
contacts: number;
inKind: number;
assets: number;
};
accounts: SeedAccountRow[];
};
@@ -71,6 +73,8 @@ const DOMAIN_COLLECTIONS = [
"contacts",
"assetPrices",
"inKindDebts",
"assets",
"assetMovements",
];
type AuthApi = ReturnType<typeof getAuth>["api"];
@@ -124,6 +128,7 @@ export async function runSeed({
installmentPlans: 0,
contacts: 0,
inKind: 0,
assets: 0,
},
accounts: [],
};
@@ -302,6 +307,8 @@ export async function runSeed({
if (summary.created.contacts) log(`✓ Örnek cariler: ${summary.created.contacts}`);
summary.created.inKind = await seedInKind(householdIds, userIdMap);
if (summary.created.inKind) log(`✓ Örnek aynî kayıtlar ve fiyatlar: ${summary.created.inKind}`);
summary.created.assets = await seedAssets(householdIds, userIdMap);
if (summary.created.assets) log(`✓ Örnek varlıklar: ${summary.created.assets}`);
// 7) Geliştirme hesapları tablosu
const all = [
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { AssetDetailView } from "@/features/assets/components/asset-detail-view";
import { getAsset } from "@/features/assets/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.assets.history };
/** /birikim/:id — varlık hareketleri (gizli varlık yalnızca sahibine; aksi 404). */
export default async function AssetPage({ params }: PageProps<"/birikim/[id]">) {
const { user, household } = await requireActiveHousehold("viewer");
const { id } = await params;
const data = await getAsset(user.id, household.id, id);
if (!data) notFound();
return <AssetDetailView key={id} data={data} />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageHeader } from "@/components/page-header";
import { AssetsView } from "@/features/assets/components/assets-view";
import { listAssets } from "@/features/assets/service";
import { tr } from "@/i18n/tr";
import { requireActiveHousehold } from "@/lib/dal";
export const metadata: Metadata = { title: tr.assets.title };
/** /birikim — altın, döviz ve ziynet varlıkları; ortalama maliyet ve kâr/zarar. */
export default async function AssetsPage() {
const { user, household } = await requireActiveHousehold("viewer");
const data = await listAssets(user.id, household.id);
if (!data) notFound();
return (
<>
<PageHeader title={tr.assets.title} description={tr.assets.description} />
<AssetsView items={data.items} totals={data.totals} canWrite={data.canWrite} />
</>
);
}
+2 -2
View File
@@ -27,7 +27,7 @@ export type NavItem = {
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
export const CURRENT_PHASE = 8.2;
export const CURRENT_PHASE = 9.1;
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
@@ -38,7 +38,7 @@ export const MAIN_NAV: NavItem[] = [
{ href: "/kategoriler", label: tr.nav.categories, icon: Tags, phase: 6.1 },
{ href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7.1 },
{ href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8.1 },
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 },
{ href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9.1 },
{ href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 },
{ href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 },
{ href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 },
+62
View File
@@ -0,0 +1,62 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { tr } from "@/i18n/tr";
import { fail, 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";
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
function fieldErrors(error: z.ZodError) {
const out: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
async function run<T>(
schema: z.ZodType<T>,
input: unknown,
fn: (userId: string, householdId: string, data: T) => Promise<ActionResult<unknown>>,
): Promise<ActionResult<unknown>> {
const ctx = await getHouseholdActionContext("member");
if (!ctx) return fail(tr.common.unauthorized);
const parsed = schema.safeParse(input);
if (!parsed.success) return fail(tr.adminUsers.errors.checkFields, fieldErrors(parsed.error));
const result = await fn(ctx.user.id, ctx.household.id, parsed.data);
if (result.ok) revalidatePath("/", "layout");
return result;
}
/** Yeni varlık (assetId boş) veya mevcut varlığı düzenle. */
export async function saveAssetAction(assetId: unknown, input: unknown) {
if (assetId) {
const id = idSchema.safeParse(assetId);
if (!id.success) return fail(tr.common.notFound);
return run(assetFormSchema, input, (u, h, d) => updateAsset(u, h, id.data, d));
}
return run(assetFormSchema, input, createAsset);
}
export async function deleteAssetAction(assetId: unknown) {
const id = idSchema.safeParse(assetId);
if (!id.success) return fail(tr.common.notFound);
return run(z.unknown(), null, (u, h) => deleteAsset(u, h, id.data));
}
export async function addMovementAction(assetId: unknown, input: unknown) {
const id = idSchema.safeParse(assetId);
if (!id.success) return fail(tr.common.notFound);
return run(movementFormSchema, input, (u, h, d) => addMovement(u, h, id.data, d));
}
export async function deleteMovementAction(movementId: unknown) {
const id = idSchema.safeParse(movementId);
if (!id.success) return fail(tr.common.notFound);
return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data));
}
@@ -0,0 +1,307 @@
"use client";
import {
ArrowLeft,
EllipsisVertical,
Gift,
HandCoins,
Lock,
Pencil,
ShoppingBag,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { PageHeader } from "@/components/page-header";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { tr } from "@/i18n/tr";
import { ASSETS, formatAssetQuantity } from "@/shared/assets";
import { formatDate, formatMoney } from "@/shared/format";
import { isInflow, type MovementKind } from "@/shared/portfolio";
import { deleteAssetAction, deleteMovementAction } from "../actions";
import type { AssetDetail, MovementRow } from "../service";
import { AssetFormDialog, MovementDialog } from "./asset-dialogs";
import { ProfitLoss, Stat } from "./assets-view";
const t = tr.assets;
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
type Pending =
| { kind: "edit" }
| { kind: "movement"; movement: MovementKind }
| { kind: "deleteMovement"; row: MovementRow }
| { kind: "deleteAsset" }
| null;
/** /birikim/:id — özet, hareket ekleme ve hareket geçmişi. */
export function AssetDetailView({ data }: { data: AssetDetail }) {
const router = useRouter();
const [pending, setPending] = useState<Pending>(null);
const close = () => setPending(null);
const s = data.summary;
const unit = ASSETS[data.assetKey].unit;
return (
<div className="flex flex-col gap-4">
<Button variant="ghost" size="sm" asChild className="-ml-2 w-fit">
<Link href="/birikim">
<ArrowLeft />
{t.back}
</Link>
</Button>
<PageHeader
title={data.name}
description={ASSETS[data.assetKey].label}
actions={
data.canWrite ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="outline" aria-label={tr.recurring.actions.more}>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setPending({ kind: "edit" })}>
<Pencil />
{tr.common.edit}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={data.movementCount > 0}
onSelect={() => setPending({ kind: "deleteAsset" })}
>
<Trash2 />
{t.delete}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null
}
/>
{data.isPrivate ? (
<Badge variant="outline" className="-mt-2 w-fit gap-1">
<Lock className="size-3" />
{t.privateBadge}
</Badge>
) : null}
<Card size="sm">
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label={t.stats.quantity}>{formatAssetQuantity(data.assetKey, s.quantity)}</Stat>
<Stat label={t.stats.avgCost}>
{s.avgCostMinor !== null
? `${formatMoney(s.avgCostMinor)} ${t.stats.perUnit(unit)}`
: "—"}
</Stat>
<Stat label={t.stats.cost}>{formatMoney(s.costMinor)}</Stat>
<Stat label={t.stats.currentValue}>
{s.currentValueMinor !== null ? formatMoney(s.currentValueMinor) : t.stats.noPrice}
</Stat>
<Stat label={t.stats.unrealized}>
<ProfitLoss minor={s.unrealizedMinor} bp={s.unrealizedBp} />
</Stat>
<Stat label={t.stats.realized}>
<ProfitLoss minor={s.realizedMinor} />
</Stat>
<Stat label={t.stats.invested}>{formatMoney(s.investedMinor)}</Stat>
<Stat label={t.stats.proceeds}>{formatMoney(s.proceedsMinor)}</Stat>
</div>
{data.price ? (
<p className="text-xs text-muted-foreground">
{t.stats.price(formatMoney(data.price.buyMinor), d(data.price.date))} ·{" "}
<Link href="/fiyatlar" className="underline-offset-2 hover:underline">
{t.prices}
</Link>
</p>
) : null}
{data.storage ? (
<p className="text-sm">
<span className="text-muted-foreground">{t.movement.storage}:</span> {data.storage}
</p>
) : null}
{data.note ? <p className="text-sm text-muted-foreground">{data.note}</p> : null}
{data.canWrite ? (
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setPending({ kind: "movement", movement: "gift_in" })}
>
<Gift />
{t.kinds.gift_in}
</Button>
{s.quantity > 0 ? (
<Button
size="sm"
variant="outline"
onClick={() => setPending({ kind: "movement", movement: "sell" })}
>
<HandCoins />
{t.kinds.sell}
</Button>
) : null}
<Button size="sm" onClick={() => setPending({ kind: "movement", movement: "buy" })}>
<ShoppingBag />
{t.kinds.buy}
</Button>
</div>
) : null}
</CardContent>
</Card>
<section className="flex flex-col gap-2" aria-labelledby="movements-title">
<h2 id="movements-title" className="text-base font-medium">
{t.history}
</h2>
{data.movements.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.noMovements}
</p>
) : (
<ul className="divide-y rounded-xl border bg-card">
{data.movements.map((row) => (
<MovementItem
key={row.id}
row={row}
data={data}
canWrite={data.canWrite}
onDelete={() => setPending({ kind: "deleteMovement", row })}
/>
))}
</ul>
)}
</section>
{pending?.kind === "edit" ? <AssetFormDialog item={data} onClose={close} /> : null}
{pending?.kind === "movement" ? (
<MovementDialog item={data} initialKind={pending.movement} onClose={close} />
) : null}
<AlertDialog
open={pending?.kind === "deleteMovement" || pending?.kind === "deleteAsset"}
onOpenChange={(o) => !o && close()}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pending?.kind === "deleteAsset" ? t.deleteTitle : t.deleteMovementTitle}
</AlertDialogTitle>
<AlertDialogDescription>
{pending?.kind === "deleteAsset" ? t.deleteDescription : t.deleteMovementDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button
variant="destructive"
onClick={async () => {
if (pending?.kind === "deleteAsset") {
const r = await deleteAssetAction(data.id);
close();
if (r.ok) {
toast.success(t.toast.deleted);
router.push("/birikim");
} else toast.error(r.error);
} else if (pending?.kind === "deleteMovement") {
const r = await deleteMovementAction(pending.row.id);
close();
if (r.ok) toast.success(t.toast.movementDeleted);
else toast.error(r.error);
}
}}
>
{pending?.kind === "deleteAsset" ? t.delete : t.deleteMovement}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function MovementItem({
row,
data,
canWrite,
onDelete,
}: {
row: MovementRow;
data: AssetDetail;
canWrite: boolean;
onDelete: () => void;
}) {
const inflow = isInflow(row.kind);
const qty = formatAssetQuantity(data.assetKey, row.quantity);
const unit = ASSETS[data.assetKey].unit;
return (
<li className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-x-2 text-sm font-medium">
<span>{t.kinds[row.kind]}</span>
<span className={inflow ? "text-income" : "text-expense"}>
{inflow ? "+" : "−"}
{qty}
</span>
</p>
<p className="text-xs text-muted-foreground">
{d(row.date)}
{row.unitPriceMinor !== null
? ` · ${formatMoney(row.unitPriceMinor)} ${t.stats.perUnit(unit)}`
: ""}
{row.account ? ` · ${row.account}` : ""}
{row.storage ? ` · ${row.storage}` : ""}
</p>
{row.note ? <p className="text-xs text-muted-foreground">{row.note}</p> : null}
<p className="text-xs text-muted-foreground">
{t.table.balance}: {formatAssetQuantity(data.assetKey, row.quantityAfter)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end text-sm">
{row.kind !== "gift_out" ? (
<span className="tabular-nums">{formatMoney(row.amountMinor)}</span>
) : (
<span className="text-xs text-muted-foreground tabular-nums">
{t.stats.cost} {formatMoney(row.costOutMinor)}
</span>
)}
{row.kind === "sell" ? (
<span className="text-xs">
{t.table.result}: <ProfitLoss minor={row.realizedMinor} />
</span>
) : null}
</div>
{canWrite && !row.fromInKind ? (
<Button
size="icon-sm"
variant="ghost"
aria-label={t.deleteMovement}
onClick={onDelete}
className="shrink-0"
>
<Trash2 />
</Button>
) : null}
</li>
);
}
@@ -0,0 +1,315 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { MoneyInput } from "@/components/money-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAccounts } from "@/features/in-kind/components/in-kind-section";
import { tr } from "@/i18n/tr";
import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets";
import { todayIstanbul } from "@/shared/dates";
import { MOVEMENT_KINDS, type MovementKind } from "@/shared/portfolio";
import { addMovementAction, saveAssetAction } from "../actions";
import type { AssetView } from "../service";
const t = tr.assets;
type Errors = Record<string, string | undefined>;
const NONE = "__none__";
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
return (
<>
<Button variant="outline" type="button" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button type="submit" form={formId} disabled={busy}>
{tr.common.save}
</Button>
</>
);
}
/** Yeni varlık / varlığı düzenle. */
export function AssetFormDialog({
item,
onClose,
onCreated,
}: {
item: AssetView | null;
onClose: () => void;
onCreated?: (id: string) => void;
}) {
const [v, setV] = useState({
assetKey: (item?.assetKey ?? "gram_gold") as AssetKey,
name: item?.name ?? "",
note: item?.note ?? "",
isPrivate: item?.isPrivate ?? false,
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
const locked = Boolean(item?.movementCount);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const r = await saveAssetAction(item?.id ?? null, {
...v,
name: v.name.trim() || ASSETS[v.assetKey].label,
});
setBusy(false);
if (r.ok) {
toast.success(t.toast.saved);
onClose();
if (!item) onCreated?.((r.data as { id: string }).id);
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
const f = t.fields;
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={item ? t.editTitle : t.createTitle}
footer={<Footer busy={busy} formId="asset-form" onClose={onClose} />}
>
<form id="asset-form" onSubmit={submit} noValidate>
<FieldGroup>
<Field data-invalid={Boolean(errors.assetKey)}>
<FieldLabel htmlFor="as-key">{f.asset}</FieldLabel>
<Select
value={v.assetKey}
onValueChange={(x) => set("assetKey", x as AssetKey)}
disabled={locked}
>
<SelectTrigger id="as-key" className="w-full">
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
</SelectTrigger>
<SelectContent>
{ASSET_KEYS.map((k) => (
<SelectItem key={k} value={k}>
{ASSETS[k].label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.assetKey ? <FieldError>{errors.assetKey}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.name)}>
<FieldLabel htmlFor="as-name">{f.name}</FieldLabel>
<Input
id="as-name"
value={v.name}
placeholder={ASSETS[v.assetKey].label}
onChange={(e) => set("name", e.target.value)}
aria-invalid={Boolean(errors.name)}
/>
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="as-note">{f.note}</FieldLabel>
<Input id="as-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
</Field>
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={v.isPrivate}
onCheckedChange={(x) => set("isPrivate", x === true)}
className="mt-0.5"
/>
<span>
{f.isPrivate}
<span className="block text-xs text-muted-foreground">{f.isPrivateHint}</span>
</span>
</label>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
/** Alım, gelen hediye, satış veya çıkış hareketi. */
export function MovementDialog({
item,
initialKind,
onClose,
}: {
item: AssetView;
initialKind: MovementKind;
onClose: () => void;
}) {
const accounts = useAccounts();
const [kind, setKind] = useState<MovementKind>(initialKind);
const [v, setV] = useState({
date: todayIstanbul(),
quantity: "",
amount: "",
unitPrice: "",
accountId: "",
storage: item.storage,
note: "",
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
const withAccount = kind === "buy" || kind === "sell";
const priced = kind !== "gift_out";
const inflow = kind === "buy" || kind === "gift_in";
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const r = await addMovementAction(item.id, {
kind,
date: v.date,
quantity: v.quantity,
amount: priced ? v.amount : "",
unitPrice: priced ? v.unitPrice : "",
accountId: withAccount ? v.accountId : "",
storage: inflow ? v.storage : "",
note: v.note,
});
setBusy(false);
if (r.ok) {
toast.success(t.toast.movement);
onClose();
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
const m = t.movement;
const unit = ASSETS[item.assetKey].unit;
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={m.title(t.kinds[kind], item.name)}
description={m.held(formatAssetQuantity(item.assetKey, item.summary.quantity))}
footer={<Footer busy={busy} formId="movement-form" onClose={onClose} />}
>
<form id="movement-form" onSubmit={submit} noValidate>
<FieldGroup>
<Tabs
value={kind}
onValueChange={(x) => {
setKind(x as MovementKind);
setErrors({});
}}
>
<TabsList className="grid h-auto w-full grid-cols-2 sm:grid-cols-4">
{MOVEMENT_KINDS.map((k) => (
<TabsTrigger key={k} value={k}>
{t.kinds[k]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<p className="-mt-2 text-xs text-muted-foreground">{t.kindHints[kind]}</p>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.quantity)}>
<FieldLabel htmlFor="mv-qty">
{m.quantity} ({unit})
</FieldLabel>
<Input
id="mv-qty"
inputMode="decimal"
value={v.quantity}
onChange={(e) => set("quantity", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.quantity)}
autoFocus
/>
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.date)}>
<FieldLabel htmlFor="mv-date">{m.date}</FieldLabel>
<Input
id="mv-date"
type="date"
value={v.date}
max={todayIstanbul()}
onChange={(e) => set("date", e.target.value)}
/>
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
</Field>
</div>
{priced ? (
<>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.amount)}>
<FieldLabel htmlFor="mv-amount">{m.amount}</FieldLabel>
<MoneyInput id="mv-amount" value={v.amount} onChange={(x) => set("amount", x)} />
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.unitPrice)}>
<FieldLabel htmlFor="mv-unit">{m.unitPrice}</FieldLabel>
<MoneyInput
id="mv-unit"
value={v.unitPrice}
onChange={(x) => set("unitPrice", x)}
/>
{errors.unitPrice ? <FieldError>{errors.unitPrice}</FieldError> : null}
</Field>
</div>
<FieldDescription className="-mt-2">
{kind === "gift_in" ? m.giftValueHint : m.priceHint}
</FieldDescription>
</>
) : null}
{withAccount ? (
<Field data-invalid={Boolean(errors.accountId)}>
<FieldLabel htmlFor="mv-account">{m.account}</FieldLabel>
<Select
value={v.accountId || NONE}
onValueChange={(x) => set("accountId", x === NONE ? "" : x)}
>
<SelectTrigger id="mv-account" className="w-full">
<SelectValue>
{accounts?.find((a) => a.id === v.accountId)?.name ?? m.accountNone}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={NONE}>{m.accountNone}</SelectItem>
{(accounts ?? []).map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldDescription>{m.accountHint}</FieldDescription>
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
</Field>
) : null}
{inflow ? (
<Field>
<FieldLabel htmlFor="mv-storage">{m.storage}</FieldLabel>
<Input
id="mv-storage"
value={v.storage}
placeholder={m.storagePlaceholder}
onChange={(e) => set("storage", e.target.value)}
/>
</Field>
) : null}
<Field>
<FieldLabel htmlFor="mv-note">{m.note}</FieldLabel>
<Input id="mv-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
</Field>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
@@ -0,0 +1,191 @@
"use client";
import { ChevronRight, Coins, Lock, Plus } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { formatMoney } from "@/shared/format";
import { formatBp } from "@/shared/portfolio";
import type { AssetTotals, AssetView } from "../service";
import { AssetFormDialog } from "./asset-dialogs";
const t = tr.assets;
/** Kâr/zarar tutarı: + yeşil, − kırmızı. */
export function ProfitLoss({
minor,
bp,
compact,
}: {
minor: number | null;
bp?: number | null;
/** Mobilde oranı gizle (dar satırlar). */
compact?: boolean;
}) {
if (minor === null) return <span className="text-muted-foreground">—</span>;
return (
<span
className={cn(
"font-semibold whitespace-nowrap tabular-nums",
minor > 0 ? "text-income" : minor < 0 ? "text-expense" : undefined,
)}
>
{minor > 0 ? "+" : minor < 0 ? "−" : ""}
{formatMoney(Math.abs(minor))}
{bp !== undefined && bp !== null ? (
<span className={cn("ml-1 text-xs font-normal", compact && "hidden sm:inline")}>
({formatBp(bp)})
</span>
) : null}
</span>
);
}
export function Stat({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<div className="font-semibold tabular-nums">{children}</div>
</div>
);
}
/** /birikim: toplamlar ve varlık kartları. */
export function AssetsView({
items,
totals,
canWrite,
}: {
items: AssetView[];
totals: AssetTotals;
canWrite: boolean;
}) {
const router = useRouter();
const [creating, setCreating] = useState(false);
const held = items.filter((i) => i.summary.quantity > 0 || i.movementCount === 0);
const closed = items.filter((i) => i.summary.quantity === 0 && i.movementCount > 0);
const unrealizedBp =
totals.costMinor > 0 ? Math.round((totals.unrealizedMinor * 10_000) / totals.costMinor) : null;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href="/fiyatlar">
<Coins />
{t.prices}
</Link>
</Button>
{canWrite ? (
<Button size="sm" onClick={() => setCreating(true)}>
<Plus />
{t.new}
</Button>
) : null}
</div>
<Card size="sm">
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label={t.totals.value}>{formatMoney(totals.valueMinor)}</Stat>
<Stat label={t.totals.cost}>{formatMoney(totals.costMinor)}</Stat>
<Stat label={t.totals.unrealized}>
<ProfitLoss minor={totals.unrealizedMinor} bp={unrealizedBp} />
</Stat>
<Stat label={t.totals.realized}>
<ProfitLoss minor={totals.realizedMinor} />
</Stat>
{totals.missingPrice ? (
<p className="col-span-full text-xs text-muted-foreground">
{t.totals.missingPrice(totals.missingPrice)}
</p>
) : null}
</CardContent>
</Card>
{items.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{t.empty}
</p>
) : null}
<ul className="flex flex-col gap-2">
{held.map((item) => (
<AssetRow key={item.id} item={item} />
))}
</ul>
{closed.length ? (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-medium text-muted-foreground">{t.closedGroup}</h2>
<ul className="flex flex-col gap-2 opacity-70">
{closed.map((item) => (
<AssetRow key={item.id} item={item} />
))}
</ul>
</section>
) : null}
{creating ? (
<AssetFormDialog
item={null}
onClose={() => setCreating(false)}
onCreated={(id) => router.push(`/birikim/${id}`)}
/>
) : null}
</div>
);
}
function AssetRow({ item }: { item: AssetView }) {
const s = item.summary;
return (
<li>
<Link
href={`/birikim/${item.id}`}
className="flex items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:bg-muted/50"
>
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400">
<Coins className="size-4.5" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center justify-between gap-2">
<p className="flex min-w-0 items-center gap-1.5 font-medium">
<span className="truncate">{item.name}</span>
{item.isPrivate ? (
<Badge variant="outline" className="shrink-0 gap-1" title={t.privateBadge}>
<Lock className="size-3" />
<span className="hidden sm:inline">{t.privateBadge}</span>
</Badge>
) : null}
</p>
<span className="shrink-0 text-sm font-semibold tabular-nums">
{s.currentValueMinor !== null ? formatMoney(s.currentValueMinor) : t.stats.noPrice}
</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<p className="min-w-0 truncate text-muted-foreground">
{item.label}
{s.avgCostMinor !== null
? ` · ${t.stats.avgCost} ${formatMoney(s.avgCostMinor)}`
: ""}
</p>
<span className="shrink-0">
{s.quantity > 0 ? (
<ProfitLoss minor={s.unrealizedMinor} bp={s.unrealizedBp} compact />
) : (
<>
{t.stats.realized}: <ProfitLoss minor={s.realizedMinor} />
</>
)}
</span>
</div>
</div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
</Link>
</li>
);
}
+328
View File
@@ -0,0 +1,328 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { listAccounts } from "@/features/accounts/service";
import { savePrice } from "@/features/in-kind/service";
import {
listTransactions,
setTransactionDeleted,
updateTransaction,
} from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { AccountModel } from "@/models/account";
import { AssetModel } from "@/models/asset";
import { AssetMovementModel } from "@/models/asset-movement";
import { AssetPriceModel } from "@/models/asset-price";
import { AuditLogModel } from "@/models/audit-log";
import { TransactionModel } from "@/models/transaction";
import { todayIstanbul } from "@/shared/dates";
import { addDays } from "@/shared/recurrence";
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
import { assetPriceFormSchema } from "@/shared/schemas/in-kind";
import { transactionFilterSchema, transactionFormSchema } from "@/shared/schemas/transaction";
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
import {
addMovement,
createAsset,
deleteAsset,
deleteMovement,
getAsset,
listAssets,
updateAsset,
} from "./service";
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
let cakir = "";
let ayse = "";
let emre = "";
let zeynep = "";
let fatma = "";
const today = todayIstanbul();
const daysAgo = (n: number) => addDays(today, -n);
beforeAll(async () => {
ids = await setupSeededDb();
cakir = ids.household("Çakır Ailesi");
ayse = ids.user("ayse");
emre = ids.user("emre");
zeynep = ids.user("zeynep");
fatma = ids.user("fatma");
// Seed örnekleri bu testin senaryolarını etkilemesin.
await Promise.all([
AssetPriceModel.deleteMany({}),
AssetModel.deleteMany({}),
AssetMovementModel.deleteMany({}),
TransactionModel.deleteMany({ assetId: { $ne: null } }),
]);
});
afterAll(teardownDb);
const acc = (name: string) =>
AccountModel.findOne({ householdId: cakir, name })
.lean()
.then((a) => String(a!._id));
async function balance(accountId: string) {
return (await listAccounts(ayse, cakir))!.accounts.find((a) => a.id === accountId)!.balanceMinor!;
}
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 : "";
}
describe("varlık lotları ve ortalama maliyet", () => {
let gold = "";
it("alım hesaptan transfer olarak çıkar; gelir/gider toplamları değişmez", async () => {
gold = await asset("Altın birikimi");
const ziraat = await acc("Ziraat Maaş");
const z0 = await balance(ziraat);
const sums0 = (await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums;
const r = await addMovement(
ayse,
cakir,
gold,
mv({
kind: "buy",
date: daysAgo(60),
quantity: "10",
amount: "25.000",
accountId: ziraat,
storage: "Ev kasası",
}),
);
expect(r.ok).toBe(true);
expect(await balance(ziraat)).toBe(z0 - 25_000_00);
expect((await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums).toEqual(
sums0,
);
// İşlem listesinde varlık adıyla görünür.
const page = (await listTransactions(
ayse,
cakir,
transactionFilterSchema.parse({ q: "Varlık alımı" }),
))!;
expect(page.items[0]).toMatchObject({ asset: { id: gold, name: "Altın birikimi" } });
});
it("birim fiyatla alım ve fiyat tablosundan (satış fiyatı) alım; özet", async () => {
await addMovement(
ayse,
cakir,
gold,
mv({ kind: "buy", date: daysAgo(30), quantity: "2", unitPrice: "2.600" }),
);
// Fiyat yok → reddedilir.
expect(
(await addMovement(ayse, cakir, gold, mv({ kind: "buy", date: daysAgo(10), quantity: "2" })))
.ok,
).toBe(false);
await savePrice(
ayse,
cakir,
assetPriceFormSchema.parse({
assetKey: "gram_gold",
date: daysAgo(10),
buy: "2.650",
sell: "2.700",
}),
);
expect(
(await addMovement(ayse, cakir, gold, mv({ kind: "buy", date: daysAgo(10), quantity: "2" })))
.ok,
).toBe(true);
await savePrice(
ayse,
cakir,
assetPriceFormSchema.parse({
assetKey: "gram_gold",
date: today,
buy: "3.000",
sell: "3.100",
}),
);
const v = (await getAsset(ayse, cakir, gold))!;
// 14 gr, 25.000 + 5.200 + 5.400 = 35.600; değer alış 3.000 ile 42.000.
expect(v.summary).toMatchObject({
quantity: 14_000,
costMinor: 35_600_00,
investedMinor: 35_600_00,
avgCostMinor: 2_542_86,
currentValueMinor: 42_000_00,
unrealizedMinor: 6_400_00,
unrealizedBp: 1798,
});
expect(v.storage).toBe("Ev kasası");
expect(v.movements[0]!.kind).toBe("buy");
});
it("satış: hesaba girer, ortalama maliyetle gerçekleşmiş K/Z; eldekinden fazlası reddedilir", async () => {
const cuzdan = await acc("Cüzdan");
const c0 = await balance(cuzdan);
expect(
(await addMovement(ayse, cakir, gold, mv({ kind: "sell", quantity: "15", amount: "1" }))).ok,
).toBe(false);
// Geçmiş tarihli satış o tarihteki miktarla sınırlı (60 gün önce yalnızca 10 gr vardı).
expect(
(
await addMovement(
ayse,
cakir,
gold,
mv({ kind: "sell", date: daysAgo(45), quantity: "11", amount: "1" }),
)
).ok,
).toBe(false);
const r = await addMovement(
ayse,
cakir,
gold,
mv({ kind: "sell", quantity: "4", amount: "12.000", accountId: cuzdan }),
);
expect(r.ok).toBe(true);
expect(await balance(cuzdan)).toBe(c0 + 12_000_00);
const v = (await getAsset(ayse, cakir, gold))!;
// 4 gr maliyeti: 35.600 × 4/14 = 10.171,43 → K/Z 1.828,57
expect(v.movements[0]).toMatchObject({
kind: "sell",
costOutMinor: 10_171_43,
realizedMinor: 1_828_57,
quantityAfter: 10_000,
});
expect(v.summary.realizedMinor).toBe(1_828_57);
expect(v.summary.proceedsMinor).toBe(12_000_00);
});
it("hediye çıkışı K/Z yazmaz, hesap hareketi oluşturmaz", async () => {
const before = (await getAsset(ayse, cakir, gold))!.summary;
const r = await addMovement(
ayse,
cakir,
gold,
mv({ kind: "gift_out", quantity: "1", note: "Yeğene" }),
);
expect(r.ok).toBe(true);
const m = await AssetMovementModel.findById(r.ok ? r.data.id : "").lean();
expect(m).toMatchObject({ amountMinor: 0, transactionId: null });
const after = (await getAsset(ayse, cakir, gold))!.summary;
expect(after.quantity).toBe(9_000);
expect(after.realizedMinor).toBe(before.realizedMinor);
expect(after.avgCostMinor).toBe(before.avgCostMinor);
});
it("bağlı işlem tek başına düzenlenemez/silinemez; hareket silinince işlem de silinir", async () => {
const tx = (await AssetMovementModel.findOne({ assetId: gold, kind: "sell" }).lean())!;
const txId = String(tx.transactionId);
const locked = { ok: false, error: tr.transactions.assetLocked };
expect(await setTransactionDeleted(ayse, cakir, txId, true)).toMatchObject(locked);
const edit = transactionFormSchema.parse({
type: "transfer",
date: today,
amount: "1",
accountId: await acc("Cüzdan"),
toAccountId: await acc("Ziraat Maaş"),
});
expect(await updateTransaction(ayse, cakir, txId, edit)).toMatchObject(locked);
const cuzdan = await acc("Cüzdan");
const c0 = await balance(cuzdan);
expect((await deleteMovement(ayse, cakir, String(tx._id))).ok).toBe(true);
expect(await balance(cuzdan)).toBe(c0 - 12_000_00);
expect(
await AuditLogModel.exists({ action: "asset.movement.delete", targetId: String(tx._id) }),
).toBeTruthy();
expect((await getAsset(ayse, cakir, gold))!.summary.realizedMinor).toBe(0);
});
it("silinince sonraki çıkış eldekini aşacaksa reddedilir; hareketli varlık silinemez", async () => {
const first = (await AssetMovementModel.findOne({ assetId: gold, kind: "buy" })
.sort({ date: 1 })
.lean())!;
// Kalan: 10 + 2 + 2 alım, bugün 1 gr çıkış. Sonraki alımlar silinebilir; ilk alım silinirse
// çıkış eldekini aşar → reddedilir.
const buys = await AssetMovementModel.find({ assetId: gold, kind: "buy", deletedAt: null })
.sort({ date: -1 })
.lean();
expect(buys.at(-1)!._id.equals(first._id)).toBe(true);
for (const b of buys.slice(0, -1))
expect((await deleteMovement(ayse, cakir, String(b._id))).ok).toBe(true);
expect((await deleteMovement(ayse, cakir, String(first._id))).ok).toBe(false);
expect((await deleteAsset(ayse, cakir, gold)).ok).toBe(false);
expect(
(await updateAsset(ayse, cakir, gold, assetFormSchema.parse({ assetKey: "usd", name: "X" })))
.ok,
).toBe(false);
});
it("gelen hediye: maliyet o günkü alış fiyatı, yatırım sayılmaz", async () => {
const z = await asset("Düğün ziynetleri", "quarter_gold");
await savePrice(
ayse,
cakir,
assetPriceFormSchema.parse({ assetKey: "quarter_gold", date: daysAgo(100), buy: "5.000" }),
);
expect(
movementFormSchema.safeParse({
kind: "gift_in",
date: today,
quantity: "1",
accountId: await acc("Cüzdan"),
}).success,
).toBe(false);
expect(
(await addMovement(ayse, cakir, z, mv({ kind: "gift_in", date: daysAgo(90), quantity: "4" })))
.ok,
).toBe(true);
const v = (await getAsset(ayse, cakir, z))!;
expect(v.summary).toMatchObject({ quantity: 4_000, costMinor: 20_000_00, investedMinor: 0 });
// Güncel değer: bugünden önceki en yakın fiyat.
expect(v.summary.currentValueMinor).toBe(20_000_00);
});
});
describe("görünürlük ve yetki", () => {
it("gizli varlık ve bağlı işlemi başkasına görünmez; izleyici yazamaz; başka hane göremez", async () => {
const secret = await asset("Emre dolar", "usd", true, emre);
await savePrice(
emre,
cakir,
assetPriceFormSchema.parse({ assetKey: "usd", date: today, buy: "40" }),
);
const r = await addMovement(
emre,
cakir,
secret,
mv({ kind: "buy", quantity: "100", amount: "4.000", accountId: await acc("Cüzdan") }),
);
expect(r.ok).toBe(true);
expect((await listAssets(emre, cakir))!.items.some((a) => a.id === secret)).toBe(true);
expect((await listAssets(ayse, cakir))!.items.some((a) => a.id === secret)).toBe(false);
expect(await getAsset(ayse, cakir, secret)).toBeNull();
const ayseTx = (await listTransactions(
ayse,
cakir,
transactionFilterSchema.parse({ q: "Emre dolar" }),
))!;
expect(ayseTx.total).toBe(0);
expect(
(await addMovement(ayse, cakir, secret, mv({ kind: "gift_out", quantity: "1" }))).ok,
).toBe(false);
const shared = (await listAssets(ayse, cakir))!.items[0]!.id;
expect(
(await addMovement(zeynep, cakir, shared, mv({ kind: "gift_out", quantity: "1" }))).ok,
).toBe(false);
expect((await listAssets(zeynep, cakir))!.canWrite).toBe(false);
expect(await listAssets(fatma, cakir)).toBeNull();
expect(await getAsset(fatma, cakir, shared)).toBeNull();
});
it("toplamlar: fiyatı olmayan varlık toplam değere katılmaz", async () => {
const eur = await asset("Euro kumbara", "eur");
await addMovement(ayse, cakir, eur, mv({ kind: "gift_in", quantity: "50", amount: "2.400" }));
const data = (await listAssets(ayse, cakir))!;
expect(data.totals.missingPrice).toBe(1);
const withPrice = data.items.filter((i) => i.summary.currentValueMinor !== null);
expect(data.totals.valueMinor).toBe(
withPrice.reduce((a, i) => a + (i.summary.currentValueMinor ?? 0), 0),
);
});
});
+484
View File
@@ -0,0 +1,484 @@
import "server-only";
import mongoose, { Types } from "mongoose";
import { hasHouseholdRole } from "@/features/households/roles";
import { priceOn, type PriceQuote } from "@/features/in-kind/service";
import { loadContext, type Ctx } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { fail, ok, type ActionResult } from "@/lib/action-result";
import { writeAudit } from "@/lib/audit";
import { toObjectId } from "@/lib/object-id";
import { AssetModel } from "@/models/asset";
import { AssetMovementModel } from "@/models/asset-movement";
import { TransactionModel } from "@/models/transaction";
import { formatAssetQuantity, valueOf, type AssetKey } from "@/shared/assets";
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
import {
holdingSummary,
runMovements,
type HoldingSummary,
type MovementKind,
} from "@/shared/portfolio";
import { QUANTITY_SCALE } from "@/shared/quantity";
import type { AssetForm, MovementForm } from "@/shared/schemas/asset";
import { compareTr, foldTr } from "@/shared/search";
/**
* Birikim ve varlık takibi (Bölüm 6.6). Okuma: viewer; yazma: member. Varlıklar hane ortaktır;
* "yalnızca ben" işaretli varlık ve hareketleri (bağlı hesap işlemleri dahil) yalnızca sahibine
* görünür. Değerleme alış fiyatıyla (Faz 0). Alım/satış hesaba bağlıysa transfer işlemi oluşur.
*/
const t = tr.assets;
type AssetDoc = {
_id: Types.ObjectId;
assetKey: AssetKey;
name: string;
note: string;
isPrivate: boolean;
ownerUserId?: Types.ObjectId | null;
};
type MovementDoc = {
_id: Types.ObjectId;
assetId: Types.ObjectId;
kind: MovementKind;
date: string;
quantity: number;
unitPriceMinor?: number | null;
amountMinor: number;
accountId?: Types.ObjectId | null;
transactionId?: Types.ObjectId | null;
storage: string;
note: string;
inKindDebtId?: Types.ObjectId | null;
createdAt: Date;
};
export type AssetView = {
id: string;
assetKey: AssetKey;
name: string;
note: string;
isPrivate: boolean;
label: string;
price: PriceQuote | null;
summary: HoldingSummary;
/** Son alımın saklama yeri. */
storage: string;
movementCount: number;
};
export type AssetTotals = {
valueMinor: number;
costMinor: number;
unrealizedMinor: number;
realizedMinor: number;
/** Fiyatı olmadığı için toplam değere katılmayan (elde miktarı olan) varlık sayısı. */
missingPrice: number;
};
export type MovementRow = {
id: string;
kind: MovementKind;
date: string;
quantity: number;
unitPriceMinor: number | null;
amountMinor: number;
account: string | null;
storage: string;
note: string;
costOutMinor: number;
realizedMinor: number;
quantityAfter: number;
fromInKind: boolean;
};
export type AssetDetail = AssetView & { movements: MovementRow[]; canWrite: boolean };
const sortMovements = (a: MovementDoc, b: MovementDoc) =>
a.date === b.date ? a.createdAt.getTime() - b.createdAt.getTime() : a.date < b.date ? -1 : 1;
function visibleAsset(ctx: Ctx, id: Types.ObjectId | string) {
const a = ctx.assetMap.get(String(id));
return a && !a.hidden && !a.deleted ? a : null;
}
async function loadMovements(householdId: Types.ObjectId, assetIds: Types.ObjectId[]) {
const docs = await AssetMovementModel.find({
householdId,
assetId: { $in: assetIds },
deletedAt: null,
}).lean<MovementDoc[]>();
const by = new Map<string, MovementDoc[]>();
for (const m of docs) {
const list = by.get(String(m.assetId)) ?? [];
list.push(m);
by.set(String(m.assetId), list);
}
for (const list of by.values()) list.sort(sortMovements);
return by;
}
function toView(a: AssetDoc, movements: MovementDoc[], price: PriceQuote | null): AssetView {
const run = runMovements(movements);
const summary = holdingSummary(run, price?.buyMinor ?? null);
const storage = [...movements].reverse().find((m) => m.storage)?.storage ?? "";
return {
id: String(a._id),
assetKey: a.assetKey,
name: a.name,
note: a.note,
isPrivate: a.isPrivate,
label: formatAssetQuantity(a.assetKey, summary.quantity),
price,
summary,
storage,
movementCount: movements.length,
};
}
/** Görünür varlıklar ve toplamlar (net varlık ve dashboard da kullanır). */
export async function listAssets(
userId: string,
householdId: string,
): Promise<{ items: AssetView[]; totals: AssetTotals; canWrite: boolean } | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
return { ...(await assetViews(ctx)), canWrite: hasHouseholdRole(ctx.m.role, "member") };
}
export async function assetViews(ctx: Ctx, date = todayIstanbul()) {
const docs = (
await AssetModel.find({ householdId: ctx.m.householdId, deletedAt: null }).lean<AssetDoc[]>()
).filter((a) => visibleAsset(ctx, a._id));
const movements = await loadMovements(
ctx.m.householdId,
docs.map((a) => a._id),
);
const prices = new Map<string, PriceQuote | null>();
for (const key of new Set(docs.map((a) => a.assetKey)))
prices.set(key, await priceOn(ctx.m.householdId, key, date));
const items = docs
.map((a) => toView(a, movements.get(String(a._id)) ?? [], prices.get(a.assetKey) ?? null))
.sort(
(x, y) =>
Number(y.summary.quantity > 0) - Number(x.summary.quantity > 0) ||
(y.summary.currentValueMinor ?? 0) - (x.summary.currentValueMinor ?? 0) ||
compareTr(x.name, y.name),
);
const totals: AssetTotals = {
valueMinor: 0,
costMinor: 0,
unrealizedMinor: 0,
realizedMinor: 0,
missingPrice: 0,
};
for (const v of items) {
totals.realizedMinor += v.summary.realizedMinor;
if (v.summary.quantity === 0) continue;
if (v.summary.currentValueMinor === null) {
totals.missingPrice++;
continue;
}
totals.valueMinor += v.summary.currentValueMinor;
totals.costMinor += v.summary.costMinor;
totals.unrealizedMinor += v.summary.unrealizedMinor ?? 0;
}
return { items, totals };
}
async function loadAsset(ctx: Ctx, assetId: string) {
const id = toObjectId(assetId);
if (!id || !visibleAsset(ctx, id)) return null;
return AssetModel.findOne({
_id: id,
householdId: ctx.m.householdId,
deletedAt: null,
}).lean<AssetDoc>();
}
export async function getAsset(
userId: string,
householdId: string,
assetId: string,
): Promise<AssetDetail | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const a = await loadAsset(ctx, assetId);
if (!a) return null;
const movements = (await loadMovements(ctx.m.householdId, [a._id])).get(String(a._id)) ?? [];
const price = await priceOn(ctx.m.householdId, a.assetKey, todayIstanbul());
const run = runMovements(movements);
const rows = movements.map((m, i): MovementRow => {
const acc = m.accountId ? ctx.accountMap.get(String(m.accountId)) : undefined;
const step = run.steps[i]!;
return {
id: String(m._id),
kind: m.kind,
date: m.date,
quantity: m.quantity,
unitPriceMinor: m.unitPriceMinor ?? null,
amountMinor: m.amountMinor,
account: acc ? (acc.hidden ? tr.transactions.list.hiddenAccount : acc.name) : null,
storage: m.storage,
note: m.note,
costOutMinor: step.costOutMinor,
realizedMinor: step.realizedMinor,
quantityAfter: step.quantityAfter,
fromInKind: Boolean(m.inKindDebtId),
};
});
return {
...toView(a, movements, price),
movements: rows.reverse(),
canWrite: hasHouseholdRole(ctx.m.role, "member"),
};
}
// --- Varlık kaydı ---
export async function createAsset(
actorId: string,
householdId: string,
input: AssetForm,
): Promise<ActionResult<{ id: string }>> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const actor = new Types.ObjectId(actorId);
const doc = await AssetModel.create({
householdId: ctx.m.householdId,
assetKey: input.assetKey,
name: input.name,
note: input.note,
isPrivate: input.isPrivate,
ownerUserId: input.isPrivate ? actor : null,
createdBy: actor,
updatedBy: actor,
});
return ok({ id: String(doc._id) });
}
export async function updateAsset(
actorId: string,
householdId: string,
assetId: string,
input: AssetForm,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const a = await loadAsset(ctx, assetId);
if (!a) return fail(tr.common.notFound);
if (
input.assetKey !== a.assetKey &&
(await AssetMovementModel.exists({ assetId: a._id, deletedAt: null }))
)
return fail(t.errors.assetKeyLocked, { assetKey: t.errors.assetKeyLocked });
const actor = new Types.ObjectId(actorId);
const owner = a.ownerUserId ?? (input.isPrivate ? actor : null);
await AssetModel.updateOne(
{ _id: a._id, householdId: ctx.m.householdId },
{
$set: {
assetKey: input.assetKey,
name: input.name,
note: input.note,
isPrivate: input.isPrivate,
ownerUserId: input.isPrivate ? owner : null,
updatedBy: actor,
},
},
);
return ok(undefined);
}
export async function deleteAsset(
actorId: string,
householdId: string,
assetId: string,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const a = await loadAsset(ctx, assetId);
if (!a) return fail(tr.common.notFound);
if (await AssetMovementModel.exists({ assetId: a._id, deletedAt: null }))
return fail(t.errors.hasMovements);
const actor = new Types.ObjectId(actorId);
await AssetModel.updateOne({ _id: a._id }, { $set: { deletedAt: new Date(), updatedBy: actor } });
await writeAudit({
action: "asset.delete",
actorId: actor,
householdId: ctx.m.householdId,
targetType: "asset",
targetId: String(a._id),
diff: { name: a.name, assetKey: a.assetKey },
});
return ok(undefined);
}
// --- Hareketler ---
function checkAccount(ctx: Ctx, id: string | null) {
if (!id) return null;
const a = ctx.accountMap.get(id);
return a && !a.hidden && !a.archived ? a : null;
}
/** Tutar ve birim fiyatı tamamlar (bkz. movementFormSchema). */
async function resolveAmount(
householdId: Types.ObjectId,
assetKey: AssetKey,
input: MovementForm,
): Promise<{ unitPriceMinor: number | null; amountMinor: number } | null> {
if (input.kind === "gift_out") return { unitPriceMinor: null, amountMinor: 0 };
if (input.amount !== null)
return {
amountMinor: input.amount,
unitPriceMinor:
input.unitPrice ?? Math.round((input.amount * QUANTITY_SCALE) / input.quantity),
};
let unit = input.unitPrice;
if (unit === null) {
const p = await priceOn(householdId, assetKey, input.date);
// Alımda kurumun satış fiyatı ödenir; satış ve hediye değeri alış fiyatıyla.
unit = p ? (input.kind === "buy" ? p.sellMinor : p.buyMinor) : null;
}
if (unit === null) return null;
return { unitPriceMinor: unit, amountMinor: valueOf(input.quantity, unit) };
}
export async function addMovement(
actorId: string,
householdId: string,
assetId: string,
input: MovementForm,
opts: { inKindDebtId?: Types.ObjectId; session?: mongoose.ClientSession } = {},
): Promise<ActionResult<{ id: string }>> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const a = await loadAsset(ctx, assetId);
if (!a) return fail(tr.common.notFound);
if (input.date > todayIstanbul()) return fail(t.errors.futureDate, { date: t.errors.futureDate });
const date = istanbulDayStart(input.date);
if (!date) return fail(tr.validation.date, { date: tr.validation.date });
const account = checkAccount(ctx, input.accountId);
if (input.accountId && !account)
return fail(tr.transactions.validation.accountInvalid, {
accountId: tr.transactions.validation.accountInvalid,
});
const amount = await resolveAmount(ctx.m.householdId, a.assetKey, input);
if (!amount) return fail(t.errors.noPrice, { amount: t.errors.noPrice });
// Eldeki miktar: yeni hareket aynı günün sonuna eklenmiş gibi doğrulanır.
const existing = (await loadMovements(ctx.m.householdId, [a._id])).get(String(a._id)) ?? [];
const now = new Date();
const candidate = [
...existing,
{ kind: input.kind, date: input.date, quantity: input.quantity, createdAt: now } as MovementDoc,
]
.map((m) => ({ ...m, amountMinor: m.amountMinor ?? 0 }))
.sort(sortMovements);
if (runMovements(candidate).negativeAt !== null)
return fail(t.errors.insufficient, { quantity: t.errors.insufficient });
const actor = new Types.ObjectId(actorId);
const movementId = new Types.ObjectId();
const label = formatAssetQuantity(a.assetKey, input.quantity);
const write = async (session: mongoose.ClientSession) => {
let transactionId: Types.ObjectId | null = null;
if (account && amount.amountMinor > 0 && (input.kind === "buy" || input.kind === "sell")) {
const out = input.kind === "buy";
const description = t.txDescription[input.kind](label, a.name);
const [tx] = await TransactionModel.create(
[
{
householdId: ctx.m.householdId,
type: "transfer",
status: "posted",
date,
// Alım hesaptan çıkar (varlığa); satış hesaba girer (varlıktan).
accountId: out ? account.id : null,
toAccountId: out ? null : account.id,
amountMinor: amount.amountMinor,
description,
searchText: foldTr(description),
assetId: a._id,
createdBy: actor,
updatedBy: actor,
},
],
{ session },
);
transactionId = tx!._id;
}
await AssetMovementModel.create(
[
{
_id: movementId,
householdId: ctx.m.householdId,
assetId: a._id,
kind: input.kind,
date: input.date,
quantity: input.quantity,
unitPriceMinor: amount.unitPriceMinor,
amountMinor: amount.amountMinor,
accountId: account && transactionId ? account.id : null,
transactionId,
storage: input.storage,
note: input.note,
inKindDebtId: opts.inKindDebtId ?? null,
createdBy: actor,
createdAt: now,
},
],
{ session },
);
};
if (opts.session) await write(opts.session);
else await mongoose.connection.transaction(write);
return ok({ id: String(movementId) });
}
export async function deleteMovement(
actorId: string,
householdId: string,
movementId: string,
): Promise<ActionResult> {
const ctx = await loadContext(actorId, householdId, "member");
if (!ctx) return fail(tr.common.unauthorized);
const id = toObjectId(movementId);
if (!id) return fail(tr.common.notFound);
const m = await AssetMovementModel.findOne({
_id: id,
householdId: ctx.m.householdId,
deletedAt: null,
}).lean<MovementDoc>();
if (!m || !visibleAsset(ctx, m.assetId)) return fail(tr.common.notFound);
const rest = (
(await loadMovements(ctx.m.householdId, [m.assetId])).get(String(m.assetId)) ?? []
).filter((x) => !x._id.equals(m._id));
if (runMovements(rest).negativeAt !== null) return fail(t.errors.deleteBreaks);
const actor = new Types.ObjectId(actorId);
await mongoose.connection.transaction(async (session) => {
const now = new Date();
await AssetMovementModel.updateOne({ _id: m._id }, { $set: { deletedAt: now } }, { session });
if (m.transactionId)
await TransactionModel.updateOne(
{ _id: m.transactionId, householdId: ctx.m.householdId },
{ $set: { deletedAt: now, updatedBy: actor } },
{ session },
);
await writeAudit(
{
action: "asset.movement.delete",
actorId: actor,
householdId: ctx.m.householdId,
targetType: "assetMovement",
targetId: String(m._id),
diff: { kind: m.kind, quantity: m.quantity, amountMinor: m.amountMinor, date: m.date },
},
session,
);
});
return ok(undefined);
}
@@ -65,7 +65,7 @@ const qtyInput = (q: number) =>
(q / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3, useGrouping: false });
/** Hesap seçenekleri (diyaloglar açıldığında). */
function useAccounts() {
export function useAccounts() {
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
useEffect(() => {
let cancelled = false;
@@ -2,6 +2,7 @@
import {
ArrowLeftRight,
Coins,
Users,
CheckCheck,
Copy,
@@ -130,6 +131,7 @@ export function rowTitle(item: TransactionListItem) {
function baseTitle(item: TransactionListItem) {
if (item.isAdjustment) return t.adjustment;
if (item.contact) return item.contact.name;
if (item.asset) return item.asset.name;
if (item.payeeName) return item.payeeName;
if (item.description) return item.description;
if (item.type === "transfer") return t.types.transfer;
@@ -141,6 +143,10 @@ export function rowSubtitle(item: TransactionListItem) {
const account = item.account?.name ?? item.toAccount?.name ?? "—";
return `${tr.contacts.kinds[item.contact.kind]} · ${account}`;
}
if (item.asset)
return item.account
? t.list.transfer(item.account.name, item.asset.name)
: t.list.transfer(item.asset.name, item.toAccount?.name ?? "—");
if (item.type === "transfer")
return t.list.transfer(item.account?.name ?? "—", item.toAccount?.name ?? "—");
const what = item.isAdjustment
@@ -183,7 +189,14 @@ function DetailDialog({
onOpenChange={onOpenChange}
title={d.title}
footer={
detail && canWrite ? (
detail && canWrite && detail.asset ? (
<Button asChild>
<Link href={`/birikim/${detail.asset.id}`}>
<Coins />
{tr.assets.open}
</Link>
</Button>
) : detail && canWrite ? (
<>
<Button variant="destructive" onClick={() => onDelete(detail)}>
<Trash2 />
@@ -239,6 +252,14 @@ function DetailDialog({
</Link>,
)
: null}
{detail.asset
? row(
tr.assets.asset,
<Link href={`/birikim/${detail.asset.id}`} className="hover:underline">
{detail.asset.name}
</Link>,
)
: null}
{detail.account
? row(
detail.type === "transfer" ? t.fields.fromAccount : t.fields.account,
+30 -2
View File
@@ -13,6 +13,7 @@ import { writeAudit } from "@/lib/audit";
import { getDb } from "@/lib/db/mongo";
import { toObjectId } from "@/lib/object-id";
import { AccountModel } from "@/models/account";
import { AssetModel } from "@/models/asset";
import { ContactModel } from "@/models/contact";
import { CategoryModel } from "@/models/category";
import { PayeeModel } from "@/models/payee";
@@ -70,7 +71,7 @@ export async function loadContext(
const m = await resolveMembership(userId, householdId, minRole);
if (!m) return null;
await postDueInstallments(m.householdId);
const [accounts, categories, contacts] = await Promise.all([
const [accounts, categories, contacts, assets] = await Promise.all([
AccountModel.find({ householdId: m.householdId })
.select({ name: 1, type: 1, archivedAt: 1, isPrivate: 1, ownerUserId: 1, icon: 1, color: 1 })
.lean(),
@@ -89,6 +90,9 @@ export async function loadContext(
ContactModel.find({ householdId: m.householdId })
.select({ name: 1, isPrivate: 1, ownerUserId: 1, archivedAt: 1 })
.lean(),
AssetModel.find({ householdId: m.householdId })
.select({ name: 1, assetKey: 1, isPrivate: 1, ownerUserId: 1, deletedAt: 1 })
.lean(),
]);
const accountMap = new Map<string, AccountInfo>(
accounts.map((a) => [
@@ -143,6 +147,19 @@ export async function loadContext(
]),
);
const hiddenContactIds = [...contactMap.values()].filter((c) => c.hidden).map((c) => c.id);
const assetMap = new Map(
assets.map((a) => [
String(a._id),
{
id: a._id,
name: a.name,
assetKey: a.assetKey,
deleted: Boolean(a.deletedAt),
hidden: Boolean(a.isPrivate) && String(a.ownerUserId) !== userId,
},
]),
);
const hiddenAssetIds = [...assetMap.values()].filter((a) => a.hidden).map((a) => a.id);
return {
m,
accountMap,
@@ -150,13 +167,15 @@ export async function loadContext(
contactMap,
hiddenAccountIds,
hiddenContactIds,
assetMap,
hiddenAssetIds,
flatCategories: flat,
};
}
export type Ctx = NonNullable<Awaited<ReturnType<typeof loadContext>>>;
/** Gizli hesaplara veya gizli carilere dokunan işlemleri dışlayan temel filtre. */
/** Gizli hesaplara, gizli carilere veya gizli varlıklara dokunan işlemleri dışlayan temel filtre. */
export function baseFilter(ctx: Ctx): Record<string, unknown> {
const f: Record<string, unknown> = { householdId: ctx.m.householdId, deletedAt: null };
if (ctx.hiddenAccountIds.length) {
@@ -164,6 +183,7 @@ export function baseFilter(ctx: Ctx): Record<string, unknown> {
f.toAccountId = { $nin: ctx.hiddenAccountIds };
}
if (ctx.hiddenContactIds.length) f.contactId = { $nin: ctx.hiddenContactIds };
if (ctx.hiddenAssetIds.length) f.assetId = { $nin: ctx.hiddenAssetIds };
return f;
}
@@ -473,6 +493,7 @@ export async function updateTransaction(
if (current.isAdjustment) return fail(t.adjustmentReadOnly);
// Cari hareketi cari sayfasındaki formla düzenlenir (tür ve yön cariye özgü).
if (current.contactId) return fail(t.contactEditHint);
if (current.assetId) return fail(t.assetLocked);
const built = buildDoc(ctx, input, current);
if (!built.ok) return built;
const d = built.data as {
@@ -534,6 +555,8 @@ export async function setTransactionDeleted(
if (!current || Boolean(current.deletedAt) === deleted) return fail(tr.common.notFound);
// Aynî borç ödemesi kaydın ödeme geçmişinin parçasıdır; tek başına silinmez.
if (current.inKindRole === "payment") return fail(t.inKindPaymentLocked);
// Varlık alım/satışı hareketin parçasıdır; hareket Birikim sayfasından silinir.
if (current.assetId) return fail(t.assetLocked);
const actor = new Types.ObjectId(actorId);
await TransactionModel.updateOne(
{ _id: current._id, householdId: ctx.m.householdId },
@@ -602,6 +625,8 @@ export type TransactionListItem = {
toAccount: { id: string; name: string } | null;
/** Cari transferi: kişi ve hareket türü. */
contact: { id: string; name: string; kind: ContactEntryKind } | null;
/** Varlık alımı/satışı: karşı taraf varlık. */
asset: { id: string; name: string } | null;
category: { id: string; name: string; path: string; icon: IconKey; color: Swatch } | null;
splitCount: number;
payeeName: string;
@@ -683,6 +708,9 @@ export function toItem(ctx: Ctx, tx: LeanTx, names: Map<string, string>): Transa
kind: tx.contactKind,
}
: null,
asset: tx.assetId
? { id: String(tx.assetId), name: ctx.assetMap.get(String(tx.assetId))?.name ?? "—" }
: null,
installment: tx.installment?.planId
? {
planId: String(tx.installment.planId),
+114
View File
@@ -364,6 +364,7 @@ export const tr = {
cancelInstallments: "Kalan taksitleri iptal et",
contactEditHint: "Cari hareketi cari sayfasından düzenlenir.",
inKindPaymentLocked: "Aynî borç ödemesi silinemez; ödeme cari sayfasındaki kayda bağlıdır.",
assetLocked: "Varlık alım/satış işlemi Birikim sayfasındaki hareketten yönetilir.",
readOnly: "İzleyici rolündesiniz; işlem ekleyemezsiniz.",
noAccounts: "İşlem girmek için önce bir hesap gerekli.",
noAccountsLink: "Hesaplara git",
@@ -640,6 +641,119 @@ export const tr = {
account: "Hesap seçin",
},
},
assets: {
title: "Birikim",
description:
"Altın, döviz ve ziynet birikimleri: alım lotları, satış ve çıkışlar, ortalama maliyet ve kâr/zarar. Değerleme alış fiyatıyla.",
new: "Varlık ekle",
asset: "Varlık",
open: "Varlığa git",
createTitle: "Yeni varlık",
editTitle: "Varlığı düzenle",
empty: "Henüz varlık yok. Bir varlık ekleyip ilk alımı girin.",
closedGroup: "Elde kalmayanlar",
privateBadge: "Yalnızca ben",
fields: {
asset: "Varlık türü",
name: "Ad",
namePlaceholder: "Ör. Gram altın birikimi",
note: "Not",
isPrivate: "Yalnızca ben görebilirim",
isPrivateHint: "İşaretlenirse bu varlık ve hareketleri hanedeki diğer üyelere görünmez.",
},
stats: {
quantity: "Eldeki miktar",
avgCost: "Ortalama maliyet",
cost: "Maliyet",
currentValue: "Güncel değer",
unrealized: "Gerçekleşmemiş K/Z",
realized: "Gerçekleşmiş K/Z",
invested: "Yatırılan",
proceeds: "Satış geliri",
noPrice: "Fiyat yok",
price: (price: string, date: string) => `Güncel alış fiyatı ${price} · ${date}`,
perUnit: (unit: string) => `/ ${unit}`,
},
totals: {
value: "Toplam güncel değer",
cost: "Toplam maliyet",
unrealized: "Gerçekleşmemiş K/Z",
realized: "Gerçekleşmiş K/Z",
missingPrice: (n: number) => `${n} varlığın fiyatı yok; toplam değere katılmadı.`,
},
kinds: {
buy: "Alım",
gift_in: "Hediye / miras geldi",
sell: "Satış",
gift_out: "Hediye / çıkış",
},
kindHints: {
buy: "Para seçilen hesaptan çıkar (transfer; gider sayılmaz).",
gift_in: "Hesap hareketi oluşmaz; maliyet o günkü değerdir.",
sell: "Satış tutarı seçilen hesaba girer; ortalama maliyete göre kâr/zarar yazılır.",
gift_out: "Hesap hareketi oluşmaz; ortalama maliyetle elden çıkar.",
},
movement: {
add: "Hareket ekle",
title: (kind: string, name: string) => `${kind} · ${name}`,
date: "Tarih",
quantity: "Miktar",
unitPrice: "Birim fiyat (TL)",
amount: "Toplam tutar (TL)",
priceHint:
"Tutar veya birim fiyattan birini girin; ikisi de boşsa fiyat tablosu kullanılır (alımda satış, satışta alış fiyatı).",
giftValueHint: "Boş bırakılırsa fiyat tablosundaki o günkü alış fiyatı kullanılır.",
account: "Hesap",
accountNone: "— Hesap yok —",
accountHint: "Hesap seçilmezse hesap hareketi oluşmaz.",
storage: "Saklama yeri",
storagePlaceholder: "Ör. ev kasası, banka kiralık kasa",
note: "Not",
held: (q: string) => `Eldeki: ${q}`,
},
history: "Hareketler",
noMovements: "Henüz hareket yok.",
table: {
date: "Tarih",
kind: "Hareket",
quantity: "Miktar",
unitPrice: "Birim fiyat",
amount: "Tutar",
result: "K/Z",
balance: "Kalan",
},
deleteMovement: "Hareketi sil",
deleteMovementTitle: "Hareket silinsin mi?",
deleteMovementDescription:
"Hareket ve bağlı hesap işlemi silinir; ortalama maliyet ve kâr/zarar yeniden hesaplanır.",
delete: "Varlığı sil",
deleteTitle: "Varlık silinsin mi?",
deleteDescription: "Yalnızca hareketi olmayan varlık silinebilir.",
prices: "Fiyat tablosu",
back: "Birikim",
txDescription: {
buy: (label: string, name: string) => `Varlık alımı: ${label} (${name})`,
sell: (label: string, name: string) => `Varlık satışı: ${label} (${name})`,
},
toast: {
saved: "Varlık kaydedildi.",
movement: "Hareket kaydedildi.",
movementDeleted: "Hareket silindi.",
deleted: "Varlık silindi.",
},
errors: {
noPrice: "Bu tarih için fiyat yok; tutar veya birim fiyat girin.",
insufficient: "Bu tarihte eldeki miktar yetersiz.",
deleteBreaks: "Bu hareket silinirse sonraki bir satış/çıkış eldeki miktarı aşar.",
hasMovements: "Hareketi olan varlık silinemez; önce hareketleri silin.",
assetKeyLocked: "Hareketi olan varlığın türü değiştirilemez.",
futureDate: "Gelecek tarihli hareket girilemez.",
privateOwner: "Yalnızca sahibi gizliliği değiştirebilir.",
},
validation: {
noAccountForGift: "Hediye hareketinde hesap seçilmez.",
},
},
prices: {
title: "Fiyat tablosu",
description:
+45
View File
@@ -0,0 +1,45 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
import { MOVEMENT_KINDS } from "@/shared/portfolio";
/**
* Varlık hareketi (lot): alım, gelen hediye, satış, hediye/çıkış. Alım ve satış bir hesaba
* bağlıysa transfer işlemi oluşturulur (`transactionId`; gelir/gider raporlarına girmez).
* Sıra: tarih, sonra oluşturulma anı.
*/
const assetMovementSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
assetId: { type: Schema.Types.ObjectId, required: true },
kind: { type: String, enum: MOVEMENT_KINDS, required: true },
/** "YYYY-MM-DD" (İstanbul). */
date: { type: String, required: true },
/** Binde bir. */
quantity: { type: Number, required: true, min: 1 },
unitPriceMinor: { type: Number, default: null },
/** Alımda ödenen, satışta alınan, gelen hediyede değer; çıkışta 0. */
amountMinor: { type: Number, default: 0, min: 0 },
accountId: { type: Schema.Types.ObjectId, default: null },
transactionId: { type: Schema.Types.ObjectId, default: null },
/** Saklama yeri (kasa, banka kiralık kasası …). */
storage: { type: String, default: "", maxlength: 120 },
note: { type: String, default: "", maxlength: 300 },
/** Aynî borç/alacak ödemesinden gelen hareket (Faz 9b). */
inKindDebtId: { type: Schema.Types.ObjectId, default: null },
deletedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "assetMovements" },
);
assetMovementSchema.index({ householdId: 1, assetId: 1, deletedAt: 1, date: 1, createdAt: 1 });
assetMovementSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type AssetMovement = InferSchemaType<typeof assetMovementSchema>;
export const AssetMovementModel: Model<AssetMovement> =
(models.AssetMovement as Model<AssetMovement> | undefined) ??
model<AssetMovement>("AssetMovement", assetMovementSchema);
+34
View File
@@ -0,0 +1,34 @@
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
import { ASSET_KEYS } from "@/shared/assets";
/**
* Varlık (Bölüm 6.6): bir varlık türünde tutulan birikim, ör. "Gram altın birikimi" veya
* "Düğün ziynetleri". Miktar ve maliyet `assetMovements`ten hesaplanır (ağırlıklı ortalama).
* "Yalnızca ben" işaretli varlık ve hareketleri yalnızca sahibine görünür.
*/
const assetSchema = new Schema(
{
householdId: { type: Schema.Types.ObjectId, required: true },
assetKey: { type: String, enum: ASSET_KEYS, required: true },
name: { type: String, required: true, trim: true, maxlength: 80 },
note: { type: String, default: "", maxlength: 500 },
isPrivate: { type: Boolean, default: false },
ownerUserId: { type: Schema.Types.ObjectId, default: null },
deletedAt: { type: Date, default: null },
seedRef: { type: String, default: null },
createdBy: { type: Schema.Types.ObjectId, required: true },
updatedBy: { type: Schema.Types.ObjectId, required: true },
},
{ timestamps: true, collection: "assets" },
);
assetSchema.index({ householdId: 1, deletedAt: 1, name: 1 });
assetSchema.index(
{ householdId: 1, seedRef: 1 },
{ unique: true, partialFilterExpression: { seedRef: { $type: "string" } } },
);
export type Asset = InferSchemaType<typeof assetSchema>;
export const AssetModel: Model<Asset> =
(models.Asset as Model<Asset> | undefined) ?? model<Asset>("Asset", assetSchema);
+2
View File
@@ -33,6 +33,8 @@ export const AUDIT_ACTIONS = [
"inkind.payment",
"inkind.close",
"inkind.delete",
"asset.delete",
"asset.movement.delete",
] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
+6
View File
@@ -64,6 +64,11 @@ const transactionSchema = new Schema(
*/
inKindDebtId: { type: Schema.Types.ObjectId, default: null },
inKindRole: { type: String, enum: ["contribution", "payment", null], default: null },
/**
* Varlık alımı (hesap → varlık) veya satışı (varlık → hesap): karşı hesap boş transfer.
* Hareketi varlık sayfasından yönetilir; görünürlük varlığa bağlıdır.
*/
assetId: { type: Schema.Types.ObjectId, default: null },
/**
* Mutabakatı yapılmış hesaplar (Bölüm 6.1). Transfer iki hesaba dokunduğu için hesap bazında
* tutulur. Tutar, tarih, tür veya hesap değişirse işaret kalkar.
@@ -88,6 +93,7 @@ transactionSchema.index({ householdId: 1, effectiveCategoryIds: 1, date: -1 });
transactionSchema.index({ householdId: 1, tags: 1 });
transactionSchema.index({ householdId: 1, contactId: 1, date: 1 }, { sparse: true });
transactionSchema.index({ "installment.planId": 1 }, { sparse: true });
transactionSchema.index({ householdId: 1, assetId: 1 }, { sparse: true });
transactionSchema.index(
{ recurringRuleId: 1, occurrenceIndex: 1 },
{ unique: true, partialFilterExpression: { recurringRuleId: { $type: "objectId" } } },
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { formatBp, holdingSummary, runMovements, type MovementInput } from "./portfolio";
const buy = (quantity: number, amountMinor: number): MovementInput => ({
kind: "buy",
quantity,
amountMinor,
});
describe("ağırlıklı ortalama maliyet", () => {
it("örnek senaryo: 10 gr 25.000 TL'ye, her ay ekleme", () => {
// 10 gr × 2.500, 2 gr × 2.600, 2 gr × 2.700 → 14 gr, 35.600 TL; ort. 2.542,857…
const run = runMovements([buy(10_000, 25_000_00), buy(2_000, 5_200_00), buy(2_000, 5_400_00)]);
expect(run.quantity).toBe(14_000);
expect(run.costMinor).toBe(35_600_00);
expect(run.investedMinor).toBe(35_600_00);
const s = holdingSummary(run, 3_000_00);
expect(s.avgCostMinor).toBe(2_542_86);
expect(s.currentValueMinor).toBe(42_000_00);
expect(s.unrealizedMinor).toBe(6_400_00);
// 6.400 / 35.600 = %17,98
expect(s.unrealizedBp).toBe(1798);
});
it("satış: ortalama maliyetle düşer, gerçekleşmiş kâr/zarar", () => {
const run = runMovements([
buy(10_000, 30_000_00),
buy(10_000, 40_000_00),
{ kind: "sell", quantity: 5_000, amountMinor: 20_000_00 },
]);
// ort. 3.500 → 5 gr maliyet 17.500; satış 20.000 → +2.500
expect(run.steps[2]).toMatchObject({ costOutMinor: 17_500_00, realizedMinor: 2_500_00 });
expect(run.quantity).toBe(15_000);
expect(run.costMinor).toBe(52_500_00);
expect(run.proceedsMinor).toBe(20_000_00);
// Kalan ortalama değişmez.
expect(holdingSummary(run, null).avgCostMinor).toBe(3_500_00);
});
it("hediye çıkışı K/Z yazmaz; gelen hediye yatırım sayılmaz; tamamı çıkınca maliyet sıfır", () => {
const run = runMovements([
buy(3_000, 9_000_00),
{ kind: "gift_in", quantity: 1_000, amountMinor: 3_300_00 },
{ kind: "gift_out", quantity: 2_000, amountMinor: 0 },
{ kind: "sell", quantity: 2_000, amountMinor: 7_000_00 },
]);
expect(run.investedMinor).toBe(9_000_00);
expect(run.giftInMinor).toBe(3_300_00);
// ort. 3.075 → çıkış 6.150, kalan 6.150; satış tümü → K/Z 850
expect(run.giftOutCostMinor).toBe(6_150_00);
expect(run.realizedMinor).toBe(850_00);
expect(run.quantity).toBe(0);
expect(run.costMinor).toBe(0);
const s = holdingSummary(run, 3_500_00);
expect(s).toMatchObject({ avgCostMinor: null, currentValueMinor: 0, unrealizedBp: null });
});
it("eldekinden fazla çıkış işaretlenir", () => {
const run = runMovements([
buy(1_000, 100_00),
{ kind: "sell", quantity: 2_000, amountMinor: 1 },
]);
expect(run.negativeAt).toBe(1);
expect(runMovements([buy(1_000, 100_00)]).negativeAt).toBeNull();
});
it("oran biçimi", () => {
expect(formatBp(1798)).toBe("+%17,98");
expect(formatBp(-350)).toBe("−%3,5");
expect(formatBp(0)).toBe("%0");
});
});
+146
View File
@@ -0,0 +1,146 @@
import { QUANTITY_SCALE } from "./quantity";
/**
* Varlık takibi hesap motoru (Bölüm 6.6): ağırlıklı ortalama maliyet, gerçekleşmiş ve
* gerçekleşmemiş kâr/zarar. Miktarlar binde bir tamsayı, tutarlar kuruş.
*
* - Alım (`buy`): miktar ve maliyet artar; ödenen TL "yatırılan" sayılır.
* - Gelen hediye/miras (`gift_in`): miktar artar; maliyet = girildiği günkü değer (yatırım değil).
* - Satış (`sell`): ortalama maliyetle maliyet düşer; satış tutarı − düşen maliyet = gerçekleşmiş K/Z.
* - Hediye/çıkış (`gift_out`): ortalama maliyetle maliyet düşer; kâr/zarar yazılmaz.
*/
export const MOVEMENT_KINDS = ["buy", "gift_in", "sell", "gift_out"] as const;
export type MovementKind = (typeof MOVEMENT_KINDS)[number];
export const isInflow = (k: MovementKind) => k === "buy" || k === "gift_in";
export type MovementInput = {
kind: MovementKind;
quantity: number;
/** Alımda ödenen, satışta alınan, gelen hediyede değer; çıkışta yok sayılır. */
amountMinor: number;
};
export type MovementStep = {
/** Satış/çıkışta ortalama maliyetle düşülen maliyet. */
costOutMinor: number;
/** Yalnızca satışta: satış tutarı − düşülen maliyet. */
realizedMinor: number;
/** Hareketten sonraki miktar ve kalan maliyet. */
quantityAfter: number;
costAfterMinor: number;
};
export type PortfolioRun = {
steps: MovementStep[];
quantity: number;
/** Kalan miktarın maliyet esası. */
costMinor: number;
/** Alımlarda ödenen toplam TL. */
investedMinor: number;
/** Satışlardan alınan toplam TL. */
proceedsMinor: number;
realizedMinor: number;
/** Gelen hediyelerin girişteki değeri. */
giftInMinor: number;
/** Çıkışlarla düşen maliyet. */
giftOutCostMinor: number;
/** Miktarın eksiye düştüğü ilk hareketin sırası (geçerli dizide null). */
negativeAt: number | null;
};
/** Hareketleri kronolojik sırada (çağıran sıralar) işler. */
export function runMovements(movements: MovementInput[]): PortfolioRun {
let quantity = 0;
let cost = 0;
const r: PortfolioRun = {
steps: [],
quantity: 0,
costMinor: 0,
investedMinor: 0,
proceedsMinor: 0,
realizedMinor: 0,
giftInMinor: 0,
giftOutCostMinor: 0,
negativeAt: null,
};
movements.forEach((m, i) => {
let costOut = 0;
let realized = 0;
if (isInflow(m.kind)) {
quantity += m.quantity;
cost += m.amountMinor;
if (m.kind === "buy") r.investedMinor += m.amountMinor;
else r.giftInMinor += m.amountMinor;
} else {
if (m.quantity > quantity) {
if (r.negativeAt === null) r.negativeAt = i;
costOut = cost;
} else {
costOut = m.quantity === quantity ? cost : Math.round((cost * m.quantity) / quantity);
}
quantity = Math.max(0, quantity - m.quantity);
cost -= costOut;
if (m.kind === "sell") {
realized = m.amountMinor - costOut;
r.proceedsMinor += m.amountMinor;
r.realizedMinor += realized;
} else r.giftOutCostMinor += costOut;
}
r.steps.push({
costOutMinor: costOut,
realizedMinor: realized,
quantityAfter: quantity,
costAfterMinor: cost,
});
});
r.quantity = quantity;
r.costMinor = cost;
return r;
}
export type HoldingSummary = {
quantity: number;
costMinor: number;
/** Birim başına ağırlıklı ortalama maliyet (miktar 0 ise null). */
avgCostMinor: number | null;
/** Güncel alış fiyatıyla değer (fiyat yoksa null). */
currentValueMinor: number | null;
unrealizedMinor: number | null;
/** Gerçekleşmemiş K/Z oranı, baz puan (%12,34 → 1234); maliyet 0 ise null. */
unrealizedBp: number | null;
realizedMinor: number;
investedMinor: number;
proceedsMinor: number;
};
/** Özet: `priceMinor` değerleme fiyatıdır (Faz 0: varlıklar alış fiyatıyla). */
export function holdingSummary(run: PortfolioRun, priceMinor: number | null): HoldingSummary {
const avgCostMinor =
run.quantity > 0 ? Math.round((run.costMinor * QUANTITY_SCALE) / run.quantity) : null;
const currentValueMinor =
priceMinor === null ? null : Math.round((run.quantity * priceMinor) / QUANTITY_SCALE);
const unrealizedMinor = currentValueMinor === null ? null : currentValueMinor - run.costMinor;
const unrealizedBp =
unrealizedMinor === null || run.costMinor <= 0
? null
: Math.round((unrealizedMinor * 10_000) / run.costMinor);
return {
quantity: run.quantity,
costMinor: run.costMinor,
avgCostMinor,
currentValueMinor,
unrealizedMinor,
unrealizedBp,
realizedMinor: run.realizedMinor,
investedMinor: run.investedMinor,
proceedsMinor: run.proceedsMinor,
};
}
/** Baz puanı "%12,34" / "−%3,5" biçiminde. */
export function formatBp(bp: number): string {
const s = (Math.abs(bp) / 100).toLocaleString("tr-TR", { maximumFractionDigits: 2 });
return `${bp < 0 ? "−" : bp > 0 ? "+" : ""}%${s}`;
}
+75
View File
@@ -0,0 +1,75 @@
import { z } from "zod";
import { tr } from "../../i18n/tr";
import { ASSET_KEYS } from "../assets";
import { parseMoney } from "../money";
import { MOVEMENT_KINDS } from "../portfolio";
import { parseQuantity } from "../quantity";
const v = tr.validation;
const a = tr.assets.validation;
const ymd = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, v.date);
const objectId = z.string().regex(/^[a-f0-9]{24}$/i, tr.transactions.validation.selectAccount);
const emptyToNull = (x: unknown) => (x === "" || x === undefined ? null : x);
/** Boş → null; dolu → pozitif kuruş. */
const optionalMoney = z.preprocess(
emptyToNull,
z
.string()
.trim()
.transform((s, ctx) => {
const minor = parseMoney(s);
if (minor === null || minor <= 0) {
ctx.addIssue({ code: "custom", message: v.money });
return z.NEVER;
}
return minor;
})
.nullable(),
);
/** Varlık (birikim kalemi) formu. */
export const assetFormSchema = z.object({
assetKey: z.enum(ASSET_KEYS),
name: z.string().trim().min(1, tr.common.required).max(80, v.nameMax),
note: z.string().trim().max(500).default(""),
isPrivate: z.boolean().default(false),
});
export type AssetFormInput = z.input<typeof assetFormSchema>;
export type AssetForm = z.output<typeof assetFormSchema>;
/**
* Varlık hareketi. Tutar ve birim fiyat isteğe bağlı: tutar varsa birim fiyat ondan, yalnızca
* birim fiyat varsa tutar ondan hesaplanır; ikisi de boşsa fiyat tablosu kullanılır
* (alım → satış fiyatı, satış ve gelen hediye → alış fiyatı). Hesap yalnızca alım/satışta.
*/
export const movementFormSchema = z
.object({
kind: z.enum(MOVEMENT_KINDS),
date: ymd,
quantity: z
.string()
.trim()
.transform((s, ctx) => {
const q = parseQuantity(s);
if (q === null || q <= 0) {
ctx.addIssue({ code: "custom", message: tr.inKind.validation.quantity });
return z.NEVER;
}
return q;
}),
unitPrice: optionalMoney,
amount: optionalMoney,
accountId: z.preprocess(emptyToNull, objectId.nullable()),
storage: z.string().trim().max(120).default(""),
note: z.string().trim().max(300).default(""),
})
.superRefine((x, ctx) => {
if ((x.kind === "gift_in" || x.kind === "gift_out") && x.accountId)
ctx.addIssue({ code: "custom", path: ["accountId"], message: a.noAccountForGift });
})
.transform((x) =>
x.kind === "gift_out" ? { ...x, unitPrice: null, amount: null, accountId: null } : x,
);
export type MovementFormInput = z.input<typeof movementFormSchema>;
export type MovementForm = z.output<typeof movementFormSchema>;