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 { z } from "zod";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, type ActionResult } from "@/lib/action-result";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
import { getHouseholdActionContext } from "@/lib/dal";
|
||||
import { assetFormSchema, movementFormSchema } from "@/shared/schemas/asset";
|
||||
import { addMovement, createAsset, deleteAsset, deleteMovement, updateAsset } from "./service";
|
||||
import {
|
||||
addMovement,
|
||||
createAsset,
|
||||
deleteAsset,
|
||||
deleteMovement,
|
||||
listAssets,
|
||||
updateAsset,
|
||||
} from "./service";
|
||||
|
||||
const idSchema = z.string().regex(/^[a-f0-9]{24}$/i);
|
||||
|
||||
@@ -60,3 +67,21 @@ export async function deleteMovementAction(movementId: unknown) {
|
||||
if (!id.success) return fail(tr.common.notFound);
|
||||
return run(z.unknown(), null, (u, h) => deleteMovement(u, h, id.data));
|
||||
}
|
||||
|
||||
export type AssetOption = { id: string; name: string; assetKey: string; quantity: number };
|
||||
|
||||
/** Aynî ödeme diyaloğu için görünür varlıklar (ad, tür, eldeki miktar). */
|
||||
export async function getAssetOptionsAction(): Promise<ActionResult<AssetOption[]>> {
|
||||
const ctx = await getHouseholdActionContext("viewer");
|
||||
if (!ctx) return fail(tr.common.unauthorized);
|
||||
const data = await listAssets(ctx.user.id, ctx.household.id);
|
||||
if (!data) return fail(tr.common.unauthorized);
|
||||
return ok(
|
||||
data.items.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
assetKey: a.assetKey,
|
||||
quantity: a.summary.quantity,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
import mongoose, { Types } from "mongoose";
|
||||
import { hasHouseholdRole } from "@/features/households/roles";
|
||||
import { priceOn, type PriceQuote } from "@/features/in-kind/service";
|
||||
import { buyPriceLookup, priceOn, type PriceQuote } from "@/features/in-kind/prices";
|
||||
import { loadContext, type Ctx } from "@/features/transactions/service";
|
||||
import { tr } from "@/i18n/tr";
|
||||
import { fail, ok, type ActionResult } from "@/lib/action-result";
|
||||
@@ -14,10 +14,14 @@ import { formatAssetQuantity, valueOf, type AssetKey } from "@/shared/assets";
|
||||
import { istanbulDayStart, todayIstanbul } from "@/shared/dates";
|
||||
import {
|
||||
holdingSummary,
|
||||
periodReport,
|
||||
runMovements,
|
||||
sumReports,
|
||||
type HoldingSummary,
|
||||
type MovementKind,
|
||||
type PeriodReport,
|
||||
} from "@/shared/portfolio";
|
||||
import { addDays, addMonths } from "@/shared/recurrence";
|
||||
import { QUANTITY_SCALE } from "@/shared/quantity";
|
||||
import type { AssetForm, MovementForm } from "@/shared/schemas/asset";
|
||||
import { compareTr, foldTr } from "@/shared/search";
|
||||
@@ -482,3 +486,58 @@ export async function deleteMovement(
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
// --- Dönemsel rapor ---
|
||||
|
||||
export const REPORT_RANGES = ["3", "6", "12", "tum"] as const;
|
||||
export type ReportRange = (typeof REPORT_RANGES)[number];
|
||||
|
||||
/**
|
||||
* Seçilen aralıkta (son N ay veya ilk hareketten bugüne) yatırılan TL, dönem sonu değer,
|
||||
* kâr/zarar ve aylık değer noktaları. `assetId` verilirse yalnızca o varlık, yoksa tüm görünür
|
||||
* varlıkların toplamı.
|
||||
*/
|
||||
export async function getPeriodReport(
|
||||
userId: string,
|
||||
householdId: string,
|
||||
range: ReportRange,
|
||||
assetId?: string,
|
||||
): Promise<(PeriodReport & { excluded: number }) | null> {
|
||||
const ctx = await loadContext(userId, householdId, "viewer");
|
||||
if (!ctx) return null;
|
||||
let docs: AssetDoc[];
|
||||
if (assetId) {
|
||||
const a = await loadAsset(ctx, assetId);
|
||||
if (!a) return null;
|
||||
docs = [a];
|
||||
} else {
|
||||
docs = (
|
||||
await AssetModel.find({ householdId: ctx.m.householdId, deletedAt: null }).lean<AssetDoc[]>()
|
||||
).filter((a) => visibleAsset(ctx, a._id));
|
||||
}
|
||||
const movements = await loadMovements(
|
||||
ctx.m.householdId,
|
||||
docs.map((a) => a._id),
|
||||
);
|
||||
const to = todayIstanbul();
|
||||
let from: string;
|
||||
if (range === "tum") {
|
||||
const first = [...movements.values()]
|
||||
.flat()
|
||||
.map((m) => m.date)
|
||||
.sort()[0];
|
||||
from = first && first < to ? first : addDays(to, -1);
|
||||
} else from = addMonths(to, -Number(range));
|
||||
const price = await buyPriceLookup(
|
||||
ctx.m.householdId,
|
||||
[...new Set(docs.map((a) => a.assetKey))],
|
||||
to,
|
||||
);
|
||||
const reports = docs.map((a) =>
|
||||
periodReport(movements.get(String(a._id)) ?? [], (date) => price(a.assetKey, date), from, to),
|
||||
);
|
||||
if (assetId) return { ...reports[0]!, excluded: 0 };
|
||||
// Toplamda fiyatı eksik varlıklar (kâr hesaplanamayan) dışarıda bırakılır ve sayılır.
|
||||
const priced = reports.filter((r) => r.profitMinor !== null);
|
||||
return { ...sumReports(priced, from, to), excluded: reports.length - priced.length };
|
||||
}
|
||||
|
||||
@@ -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 { InKindDebtModel } from "@/models/in-kind-debt";
|
||||
import { TransactionModel } from "@/models/transaction";
|
||||
import { addMovement } from "@/features/assets/service";
|
||||
import { AssetMovementModel } from "@/models/asset-movement";
|
||||
import { priceOn, type PriceQuote } from "./prices";
|
||||
import {
|
||||
ASSET_KEYS,
|
||||
formatAssetQuantity,
|
||||
@@ -38,19 +41,7 @@ const t = tr.inKind;
|
||||
|
||||
// --- Fiyat tablosu ---
|
||||
|
||||
export type PriceQuote = { buyMinor: number; sellMinor: number; date: string };
|
||||
|
||||
/** Varlığın verilen tarihteki (veya öncesindeki en yakın) fiyatı. */
|
||||
export async function priceOn(
|
||||
householdId: Types.ObjectId,
|
||||
assetKey: AssetKey,
|
||||
date: string,
|
||||
): Promise<PriceQuote | null> {
|
||||
const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } })
|
||||
.sort({ date: -1 })
|
||||
.lean();
|
||||
return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null;
|
||||
}
|
||||
export { priceOn, type PriceQuote } from "./prices";
|
||||
|
||||
export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
|
||||
|
||||
@@ -381,6 +372,11 @@ export async function deleteInKind(
|
||||
{ _id: d._id },
|
||||
{ $set: { deletedAt: new Date(), updatedBy: actor } },
|
||||
);
|
||||
// Bağlı varlık hareketleri serbest kalır (Birikim sayfasından yönetilebilir).
|
||||
await AssetMovementModel.updateMany(
|
||||
{ householdId: ctx.m.householdId, inKindDebtId: d._id },
|
||||
{ $set: { inKindDebtId: null } },
|
||||
);
|
||||
await writeAudit({
|
||||
action: "inkind.delete",
|
||||
actorId: actor,
|
||||
@@ -491,7 +487,15 @@ export async function recordPayment(
|
||||
contactName,
|
||||
);
|
||||
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
if (input.assetId) {
|
||||
const asset = ctx.assetMap.get(input.assetId);
|
||||
if (!asset || asset.hidden || asset.deleted)
|
||||
return fail(tr.common.notFound, { assetId: tr.common.notFound });
|
||||
if (asset.assetKey !== d.assetKey)
|
||||
return fail(t.link.assetMismatch, { assetId: t.link.assetMismatch });
|
||||
}
|
||||
|
||||
const writePayment = async (session: mongoose.ClientSession) => {
|
||||
let transactionId: Types.ObjectId | null = null;
|
||||
if (amountMinor > 0 && account) {
|
||||
const out = d.direction === "debt";
|
||||
@@ -549,10 +553,50 @@ export async function recordPayment(
|
||||
},
|
||||
session,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await mongoose.connection.transaction(async (session) => {
|
||||
// Varlık bağlantısı: borç ödemesi varlıktan çıkış, alacak tahsilatı varlığa giriş.
|
||||
if (input.assetId) {
|
||||
const r = await addMovement(
|
||||
actorId,
|
||||
householdId,
|
||||
input.assetId,
|
||||
{
|
||||
kind: d.direction === "debt" ? "gift_out" : "gift_in",
|
||||
date: input.date,
|
||||
quantity,
|
||||
unitPrice: d.direction === "debt" ? null : (d.unitPriceMinor ?? null),
|
||||
amount: null,
|
||||
accountId: null,
|
||||
storage: "",
|
||||
note: t.link.movementNote(contactName),
|
||||
},
|
||||
{ inKindDebtId: d._id, session },
|
||||
);
|
||||
if (!r.ok) throw new LinkError(r.error, r.fieldErrors?.quantity);
|
||||
}
|
||||
await writePayment(session);
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LinkError)
|
||||
return fail(error.message, error.quantity ? { quantity: error.quantity } : undefined);
|
||||
throw error;
|
||||
}
|
||||
return ok({ closed });
|
||||
}
|
||||
|
||||
/** Varlık hareketi eklenemedi: transaction geri alınır, hata kullanıcıya döner. */
|
||||
class LinkError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly quantity?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */
|
||||
export async function openInKindByContact(
|
||||
userId: string,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user