From 9781cd9ed2e74f1f13236f03a5a818b3563b11c7 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 5 Nov 2026 12:37:48 +0300 Subject: [PATCH] =?UTF-8?q?feat(in-kind):=20varl=C4=B1k=20t=C3=BCrleri,=20?= =?UTF-8?q?fiyat=20tablosu,=20ayn=C3=AE=20bor=C3=A7/alacak,=20birikim=20pl?= =?UTF-8?q?an=C4=B1=20ve=20=C3=B6deme=20ak=C4=B1=C5=9F=C4=B1,=20008=20migr?= =?UTF-8?q?ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/migrations/008-in-kind.ts | 18 + scripts/migrations/index.ts | 3 +- src/features/contacts/service.ts | 3 + src/features/in-kind/actions.ts | 82 ++++ src/features/in-kind/service.int.test.ts | 270 +++++++++++ src/features/in-kind/service.ts | 573 +++++++++++++++++++++++ src/features/transactions/service.ts | 2 + src/features/transactions/statement.ts | 9 +- src/models/asset-price.ts | 29 ++ src/models/audit-log.ts | 3 + src/models/in-kind-debt.ts | 61 +++ src/models/transaction.ts | 6 + src/shared/assets.test.ts | 64 +++ src/shared/assets.ts | 118 +++++ src/shared/schemas/in-kind.ts | 116 +++++ 15 files changed, 1354 insertions(+), 3 deletions(-) create mode 100644 scripts/migrations/008-in-kind.ts create mode 100644 src/features/in-kind/actions.ts create mode 100644 src/features/in-kind/service.int.test.ts create mode 100644 src/features/in-kind/service.ts create mode 100644 src/models/asset-price.ts create mode 100644 src/models/in-kind-debt.ts create mode 100644 src/shared/assets.test.ts create mode 100644 src/shared/assets.ts create mode 100644 src/shared/schemas/in-kind.ts diff --git a/scripts/migrations/008-in-kind.ts b/scripts/migrations/008-in-kind.ts new file mode 100644 index 0000000..79486c2 --- /dev/null +++ b/scripts/migrations/008-in-kind.ts @@ -0,0 +1,18 @@ +import { AssetPriceModel } from "@/models/asset-price"; +import { InKindDebtModel } from "@/models/in-kind-debt"; +import type { Migration } from "./types"; + +export const migration: Migration = { + name: "008-in-kind", + description: "assetPrices ve inKindDebts index'leri; işlemlerde aynî borç alanları", + async up(db) { + await db + .collection("transactions") + .updateMany( + { inKindDebtId: { $exists: false } }, + { $set: { inKindDebtId: null, inKindRole: null } }, + ); + await AssetPriceModel.createIndexes(); + await InKindDebtModel.createIndexes(); + }, +}; diff --git a/scripts/migrations/index.ts b/scripts/migrations/index.ts index 5d7850c..8b84d58 100644 --- a/scripts/migrations/index.ts +++ b/scripts/migrations/index.ts @@ -7,10 +7,11 @@ import { migration as m004 } from "./004-attachments-reconciliation"; 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 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]; +export const MIGRATIONS: Migration[] = [m001, m002, m003, m004, m005, m006, m007, m008]; const COLLECTION = "migrations"; diff --git a/src/features/contacts/service.ts b/src/features/contacts/service.ts index 1ea817f..41132f0 100644 --- a/src/features/contacts/service.ts +++ b/src/features/contacts/service.ts @@ -65,6 +65,8 @@ async function contactTotals(householdId: Types.ObjectId, contactIds?: Types.Obj status: "posted", deletedAt: null, contactId: contactIds ? { $in: contactIds } : { $type: "objectId" }, + // Aynî borç hareketleri cari TL bakiyesine girmez. + contactKind: { $ne: null }, }, }, { @@ -404,6 +406,7 @@ export async function getContactStatement( const txs = (await TransactionModel.find({ householdId: ctx.m.householdId, contactId: contact._id, + contactKind: { $ne: null }, status: "posted", deletedAt: null, }) diff --git a/src/features/in-kind/actions.ts b/src/features/in-kind/actions.ts new file mode 100644 index 0000000..7bbe335 --- /dev/null +++ b/src/features/in-kind/actions.ts @@ -0,0 +1,82 @@ +"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 { + assetPriceFormSchema, + contributionSchema, + inKindFormSchema, + paymentFormSchema, +} from "@/shared/schemas/in-kind"; +import { + addContribution, + createInKind, + deleteInKind, + recordPayment, + savePrice, + updateInKind, +} from "./service"; + +const idSchema = z.string().regex(/^[a-f0-9]{24}$/i); + +function fieldErrors(error: z.ZodError) { + const out: Record = {}; + for (const issue of error.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +const revalidate = () => revalidatePath("/", "layout"); + +async function run( + schema: z.ZodType, + input: unknown, + fn: (userId: string, householdId: string, data: T) => Promise>, +): Promise> { + 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) revalidate(); + return result; +} + +export async function savePriceAction(input: unknown) { + return run(assetPriceFormSchema, input, savePrice); +} + +/** Yeni aynî kayıt (debtId boş, contactId dolu) veya mevcut kaydı düzenle. */ +export async function saveInKindAction(contactId: unknown, debtId: unknown, input: unknown) { + if (debtId) { + const id = idSchema.safeParse(debtId); + if (!id.success) return fail(tr.common.notFound); + return run(inKindFormSchema, input, (u, h, d) => updateInKind(u, h, id.data, d)); + } + const cid = idSchema.safeParse(contactId); + if (!cid.success) return fail(tr.common.notFound); + return run(inKindFormSchema, input, (u, h, d) => createInKind(u, h, cid.data, d)); +} + +export async function deleteInKindAction(debtId: unknown): Promise> { + const id = idSchema.safeParse(debtId); + if (!id.success) return fail(tr.common.notFound); + return run(z.unknown(), null, (u, h) => deleteInKind(u, h, id.data)); +} + +export async function addContributionAction(debtId: unknown, input: unknown) { + const id = idSchema.safeParse(debtId); + if (!id.success) return fail(tr.common.notFound); + return run(contributionSchema, input, (u, h, d) => addContribution(u, h, id.data, d)); +} + +export async function recordPaymentAction(debtId: unknown, input: unknown) { + const id = idSchema.safeParse(debtId); + if (!id.success) return fail(tr.common.notFound); + return run(paymentFormSchema, input, (u, h, d) => recordPayment(u, h, id.data, d)); +} diff --git a/src/features/in-kind/service.int.test.ts b/src/features/in-kind/service.int.test.ts new file mode 100644 index 0000000..9a7b699 --- /dev/null +++ b/src/features/in-kind/service.int.test.ts @@ -0,0 +1,270 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { listAccounts } from "@/features/accounts/service"; +import { createContact, listContacts } from "@/features/contacts/service"; +import { listTransactions, setTransactionDeleted } from "@/features/transactions/service"; +import { AccountModel } from "@/models/account"; +import { AssetPriceModel } from "@/models/asset-price"; +import { AuditLogModel } from "@/models/audit-log"; +import { InKindDebtModel } from "@/models/in-kind-debt"; +import { todayIstanbul } from "@/shared/dates"; +import { addDays, addMonths } from "@/shared/recurrence"; +import { contactFormSchema } from "@/shared/schemas/contact"; +import { + assetPriceFormSchema, + contributionSchema, + inKindFormSchema, + paymentFormSchema, +} from "@/shared/schemas/in-kind"; +import { transactionFilterSchema } from "@/shared/schemas/transaction"; +import { setupSeededDb, teardownDb } from "@/test/seeded-db"; +import { + addContribution, + createInKind, + listInKind, + listPrices, + recordPayment, + savePrice, +} from "./service"; + +let ids: Awaited>; +let cakir = ""; +let ayse = ""; +let emre = ""; +let zeynep = ""; +const today = todayIstanbul(); +const monthAgo = addDays(today, -30); + +beforeAll(async () => { + ids = await setupSeededDb(); + cakir = ids.household("Çakır Ailesi"); + ayse = ids.user("ayse"); + emre = ids.user("emre"); + zeynep = ids.user("zeynep"); + // Seed örnek fiyatları bu testin fiyat senaryolarını etkilemesin. + await AssetPriceModel.deleteMany({}); +}); +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 price = (assetKey: string, date: string, buy: string, sell = "") => + assetPriceFormSchema.parse({ assetKey, date, buy, sell }); +async function contact(name: string, isPrivate = false) { + const r = await createContact( + ayse, + cakir, + contactFormSchema.parse({ name, openingDate: monthAgo, isPrivate }), + ); + return r.ok ? r.data.id : ""; +} +async function view(id: string) { + return (await listInKind(ayse, cakir))!.find((v) => v.id === id)!; +} + +describe("fiyat tablosu", () => { + it("elle fiyat; tek fiyat ikisine yazılır; gelecek tarih reddedilir; izleyici yazamaz", async () => { + expect((await savePrice(ayse, cakir, price("gram_gold", today, "3.200", "3.300"))).ok).toBe( + true, + ); + expect((await savePrice(ayse, cakir, price("usd", today, "41,50"))).ok).toBe(true); + expect((await savePrice(ayse, cakir, price("usd", addDays(today, 1), "1"))).ok).toBe(false); + expect((await savePrice(zeynep, cakir, price("usd", today, "1"))).ok).toBe(false); + const rows = (await listPrices(ayse, cakir))!.rows; + expect(rows.find((r) => r.assetKey === "usd")!.latest).toMatchObject({ + buyMinor: 41_50, + sellMinor: 41_50, + }); + // Aynı gün yeniden giriş günceller. + await savePrice(ayse, cakir, price("usd", today, "42")); + expect( + (await listPrices(ayse, cakir))!.rows.find((r) => r.assetKey === "usd")!.history, + ).toHaveLength(1); + }); +}); + +describe("aynî borç", () => { + let gold = ""; + let ahmet = ""; + + it("borç tarihindeki fiyat tablodan; yoksa birim fiyat istenir; değer satış fiyatıyla", async () => { + ahmet = await contact("Altın Ahmet"); + const input = inKindFormSchema.parse({ + direction: "debt", + assetKey: "gram_gold", + quantity: "5", + date: monthAgo, + savingsAccountId: await acc("Birikim (Vadeli)"), + monthlyPlan: "2.000", + }); + expect((await createInKind(ayse, cakir, ahmet, input)).ok).toBe(false); + await savePrice(ayse, cakir, price("gram_gold", monthAgo, "2.900", "3.000")); + const r = await createInKind(ayse, cakir, ahmet, input); + expect(r.ok).toBe(true); + gold = r.ok ? r.data.id : ""; + const v = await view(gold); + expect(v).toMatchObject({ + unitPriceMinor: 3_000_00, + valueMinor: 15_000_00, + label: "5 gr Gram altın (24 ayar)", + }); + // Güncel: 5 × 3.300 (satış) = 16.500; birikim 0; ayda 2.000 → 9 ay. + expect(v.progress).toMatchObject({ + currentValueMinor: 16_500_00, + accumulatedMinor: 0, + remainingNeedMinor: 16_500_00, + estimatedCloseMonth: addMonths(`${today.slice(0, 7)}-01`, 9).slice(0, 7), + }); + }); + + it("birikim katkısı: hesaptan birikim hesabına transfer; borç kapanmaz; cari TL bakiyesi ve gelir/gider değişmez", async () => { + const ziraat = await acc("Ziraat Maaş"); + const birikim = await acc("Birikim (Vadeli)"); + const [z0, b0] = [await balance(ziraat), await balance(birikim)]; + const sums0 = (await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums; + const r = await addContribution( + ayse, + cakir, + gold, + contributionSchema.parse({ fromAccountId: ziraat, amount: "6.000", date: today }), + ); + expect(r.ok).toBe(true); + expect(await balance(ziraat)).toBe(z0 - 6_000_00); + expect(await balance(birikim)).toBe(b0 + 6_000_00); + const v = await view(gold); + expect(v.progress.accumulatedMinor).toBe(6_000_00); + expect(v.progress.remainingNeedMinor).toBe(10_500_00); + expect(v.closedAt).toBeNull(); + expect( + (await listContacts(ayse, cakir))!.contacts.find((c) => c.id === ahmet)!.balanceMinor, + ).toBe(0); + expect((await listTransactions(ayse, cakir, transactionFilterSchema.parse({})))!.sums).toEqual( + sums0, + ); + }); + + it("TL karşılığıyla kısmi ödeme: miktar güncel fiyattan; birikim hesabından çıkış", async () => { + const birikim = await acc("Birikim (Vadeli)"); + const b0 = await balance(birikim); + const r = await recordPayment( + ayse, + cakir, + gold, + paymentFormSchema.parse({ method: "cash", date: today, amount: "6.600", accountId: birikim }), + ); + expect(r).toMatchObject({ ok: true, data: { closed: false } }); + const v = await view(gold); + expect(v.paidQuantity).toBe(2_000); + expect(v.progress.remainingQuantity).toBe(3_000); + expect(v.progress.accumulatedMinor).toBe(0); + expect(await balance(birikim)).toBe(b0 - 6_600_00); + // Ödeme işlemi tek başına silinemez. + const doc = (await InKindDebtModel.findById(gold).lean())!; + expect( + (await setTransactionDeleted(ayse, cakir, String(doc.payments[0]!.transactionId), true)).ok, + ).toBe(false); + }); + + it("aynı varlıkla ödeme kalanı aşarsa kalana sınırlanır ve kayıt kapanır", async () => { + const r = await recordPayment( + ayse, + cakir, + gold, + paymentFormSchema.parse({ method: "asset", date: today, quantity: "5" }), + ); + expect(r).toMatchObject({ ok: true, data: { closed: true } }); + const v = await view(gold); + expect(v.paidQuantity).toBe(5_000); + expect(v.closedAt).toBe(today); + expect(await AuditLogModel.exists({ action: "inkind.close", targetId: gold })).toBeTruthy(); + expect( + ( + await recordPayment( + ayse, + cakir, + gold, + paymentFormSchema.parse({ method: "asset", date: today, quantity: "1" }), + ) + ).ok, + ).toBe(false); + }); + + it("aynî alacak: alış fiyatıyla değerlenir, tahsilat hesaba girer; birikim planı alınmaz", async () => { + const elif = await contact("Döviz Elif"); + const cuzdan = await acc("Cüzdan"); + expect( + ( + await createInKind( + ayse, + cakir, + elif, + inKindFormSchema.parse({ + direction: "receivable", + assetKey: "usd", + quantity: "100", + date: today, + savingsAccountId: await acc("Birikim (Vadeli)"), + }), + ) + ).ok, + ).toBe(false); + const r = await createInKind( + ayse, + cakir, + elif, + inKindFormSchema.parse({ + direction: "receivable", + assetKey: "usd", + quantity: "100", + date: today, + }), + ); + const id = r.ok ? r.data.id : ""; + expect((await view(id)).progress.currentValueMinor).toBe(4_200_00); + const c0 = await balance(cuzdan); + await recordPayment( + ayse, + cakir, + id, + paymentFormSchema.parse({ method: "cash", date: today, amount: "2.100", accountId: cuzdan }), + ); + expect(await balance(cuzdan)).toBe(c0 + 2_100_00); + expect((await view(id)).paidQuantity).toBe(50_000); + }); + + it("gizli cari: aynî kayıt başkasına görünmez ve eklenemez", async () => { + const secret = await contact("Gizli Altın", true); + await createInKind( + ayse, + cakir, + secret, + inKindFormSchema.parse({ + direction: "debt", + assetKey: "gram_gold", + quantity: "1", + date: today, + }), + ); + expect((await listInKind(emre, cakir))!.some((v) => v.contactId === secret)).toBe(false); + expect(await listInKind(emre, cakir, secret)).toBeNull(); + expect( + ( + await createInKind( + emre, + cakir, + secret, + inKindFormSchema.parse({ + direction: "debt", + assetKey: "gram_gold", + quantity: "1", + date: today, + }), + ) + ).ok, + ).toBe(false); + }); +}); diff --git a/src/features/in-kind/service.ts b/src/features/in-kind/service.ts new file mode 100644 index 0000000..da82de0 --- /dev/null +++ b/src/features/in-kind/service.ts @@ -0,0 +1,573 @@ +import "server-only"; +import mongoose, { Types } from "mongoose"; +import { hasHouseholdRole } from "@/features/households/roles"; +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 { AssetPriceModel } from "@/models/asset-price"; +import { InKindDebtModel } from "@/models/in-kind-debt"; +import { TransactionModel } from "@/models/transaction"; +import { + ASSET_KEYS, + formatAssetQuantity, + inKindProgress, + quantityForAmount, + valuationPrice, + valueOf, + type AssetKey, + type InKindDirection, + type InKindProgress, +} from "@/shared/assets"; +import { istanbulDayStart, todayIstanbul } from "@/shared/dates"; +import type { + AssetPriceForm, + ContributionForm, + InKindForm, + PaymentForm, +} from "@/shared/schemas/in-kind"; +import { foldTr } from "@/shared/search"; + +/** + * Aynî borç/alacak ve fiyat tablosu (Bölüm 6.5–6.6). Okuma: viewer; yazma: member. + * Görünürlük cariye bağlıdır ("yalnızca ben" cari → kayıt da gizli). + */ + +const t = tr.inKind; + +// --- Fiyat tablosu --- + +export type PriceQuote = { buyMinor: number; sellMinor: number; date: string }; + +/** Varlığın verilen tarihteki (veya öncesindeki en yakın) fiyatı. */ +export async function priceOn( + householdId: Types.ObjectId, + assetKey: AssetKey, + date: string, +): Promise { + const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } }) + .sort({ date: -1 }) + .lean(); + return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null; +} + +export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] }; + +export async function listPrices( + userId: string, + householdId: string, +): Promise<{ rows: PriceRow[]; canWrite: boolean } | null> { + const ctx = await loadContext(userId, householdId, "viewer"); + if (!ctx) return null; + const all = await AssetPriceModel.find({ householdId: ctx.m.householdId }) + .sort({ date: -1 }) + .limit(1000) + .lean(); + const rows = ASSET_KEYS.map((assetKey): PriceRow => { + const history = all + .filter((p) => p.assetKey === assetKey) + .slice(0, 10) + .map((p) => ({ buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date })); + return { assetKey, latest: history[0] ?? null, history }; + }); + return { rows, canWrite: hasHouseholdRole(ctx.m.role, "member") }; +} + +export async function savePrice( + actorId: string, + householdId: string, + input: AssetPriceForm, +): Promise { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + if (input.date > todayIstanbul()) return fail(tr.validation.date, { date: tr.validation.date }); + await AssetPriceModel.updateOne( + { householdId: ctx.m.householdId, assetKey: input.assetKey, date: input.date }, + { + $set: { buyMinor: input.buyMinor, sellMinor: input.sellMinor, source: "manual" }, + $setOnInsert: { createdBy: new Types.ObjectId(actorId) }, + }, + { upsert: true }, + ); + return ok(undefined); +} + +// --- Aynî kayıtlar --- + +type PaymentDoc = { + _id: Types.ObjectId; + date: string; + method: "asset" | "cash"; + quantity: number; + amountMinor: number; + accountId?: Types.ObjectId | null; + transactionId?: Types.ObjectId | null; + note: string; +}; + +type DebtDoc = { + _id: Types.ObjectId; + householdId: Types.ObjectId; + contactId: Types.ObjectId; + direction: InKindDirection; + assetKey: AssetKey; + quantity: number; + date: string; + unitPriceMinor?: number | null; + valueMinor?: number | null; + note: string; + savingsAccountId?: Types.ObjectId | null; + monthlyPlanMinor?: number | null; + payments: PaymentDoc[]; + closedAt?: string | null; +}; + +export type InKindView = { + id: string; + contactId: string; + contactName: string; + direction: InKindDirection; + assetKey: AssetKey; + label: string; + quantity: number; + paidQuantity: number; + date: string; + unitPriceMinor: number | null; + valueMinor: number | null; + note: string; + savingsAccount: { id: string; name: string } | null; + monthlyPlanMinor: number | null; + closedAt: string | null; + currentPrice: PriceQuote | null; + progress: InKindProgress; + payments: { + id: string; + date: string; + method: "asset" | "cash"; + quantity: number; + amountMinor: number; + account: string | null; + note: string; + }[]; +}; + +function contactVisible(ctx: Ctx, contactId: Types.ObjectId | string) { + const c = ctx.contactMap.get(String(contactId)); + return c && !c.hidden ? c : null; +} + +/** Birikim: borca bağlı katkılar (birikim hesabına giren) − birikim hesabından yapılan ödemeler. */ +async function savingsFlows(debtIds: Types.ObjectId[]) { + const rows = await TransactionModel.aggregate<{ + _id: { debt: Types.ObjectId; role: string }; + sum: number; + }>([ + { + $match: { + inKindDebtId: { $in: debtIds }, + status: "posted", + deletedAt: null, + }, + }, + { + $group: { + _id: { debt: "$inKindDebtId", role: "$inKindRole" }, + sum: { $sum: "$amountMinor" }, + }, + }, + ]); + const map = new Map(); + for (const r of rows) { + if (r._id.role !== "contribution") continue; + map.set(String(r._id.debt), { contributed: r.sum }); + } + return map; +} + +async function toViews(ctx: Ctx, docs: DebtDoc[]): Promise { + const today = todayIstanbul(); + const flows = await savingsFlows(docs.map((d) => d._id)); + const priceCache = new Map(); + const views: InKindView[] = []; + for (const d of docs) { + if (!priceCache.has(d.assetKey)) + priceCache.set(d.assetKey, await priceOn(ctx.m.householdId, d.assetKey, today)); + const price = priceCache.get(d.assetKey)!; + const paidQuantity = d.payments.reduce((a, p) => a + p.quantity, 0); + const savingsId = d.savingsAccountId ? String(d.savingsAccountId) : null; + // Birikim hesabından yapılan ödemeler birikimden düşer. + const spentMinor = d.payments + .filter((p) => savingsId && String(p.accountId) === savingsId) + .reduce((a, p) => a + p.amountMinor, 0); + const savings = savingsId ? ctx.accountMap.get(savingsId) : undefined; + views.push({ + id: String(d._id), + contactId: String(d.contactId), + contactName: ctx.contactMap.get(String(d.contactId))?.name ?? "—", + direction: d.direction, + assetKey: d.assetKey, + label: formatAssetQuantity(d.assetKey, d.quantity), + quantity: d.quantity, + paidQuantity, + date: d.date, + unitPriceMinor: d.unitPriceMinor ?? null, + valueMinor: d.valueMinor ?? null, + note: d.note, + savingsAccount: savings && !savings.hidden ? { id: savingsId!, name: savings.name } : null, + monthlyPlanMinor: d.monthlyPlanMinor ?? null, + closedAt: d.closedAt ?? null, + currentPrice: price, + progress: inKindProgress({ + quantity: d.quantity, + paidQuantity, + unitPriceMinor: valuationPrice(d.direction, price), + contributedMinor: flows.get(String(d._id))?.contributed ?? 0, + spentMinor, + monthlyPlanMinor: d.direction === "debt" ? (d.monthlyPlanMinor ?? null) : null, + today, + }), + payments: d.payments.map((p) => { + const a = p.accountId ? ctx.accountMap.get(String(p.accountId)) : undefined; + return { + id: String(p._id), + date: p.date, + method: p.method, + quantity: p.quantity, + amountMinor: p.amountMinor, + account: a ? (a.hidden ? tr.transactions.list.hiddenAccount : a.name) : null, + note: p.note, + }; + }), + }); + } + return views; +} + +/** Bir carinin (veya tüm görünür carilerin) aynî kayıtları; açıklar önce. */ +export async function listInKind( + userId: string, + householdId: string, + contactId?: string, +): Promise { + const ctx = await loadContext(userId, householdId, "viewer"); + if (!ctx) return null; + const filter: Record = { householdId: ctx.m.householdId, deletedAt: null }; + if (contactId) { + const id = toObjectId(contactId); + if (!id || !contactVisible(ctx, id)) return null; + filter.contactId = id; + } else { + filter.contactId = { $nin: ctx.hiddenContactIds }; + } + const docs = await InKindDebtModel.find(filter).sort({ closedAt: 1, date: -1 }).lean(); + return toViews( + ctx, + docs.filter((d) => contactVisible(ctx, d.contactId)), + ); +} + +async function loadDebt(ctx: Ctx, debtId: string) { + const id = toObjectId(debtId); + if (!id) return null; + const d = await InKindDebtModel.findOne({ + _id: id, + householdId: ctx.m.householdId, + deletedAt: null, + }).lean(); + return d && contactVisible(ctx, d.contactId) ? d : null; +} + +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; +} + +async function debtFields(ctx: Ctx, input: InKindForm) { + let unitPriceMinor = input.unitPrice; + if (unitPriceMinor === null) { + const p = await priceOn(ctx.m.householdId, input.assetKey, input.date); + unitPriceMinor = valuationPrice(input.direction, p); + } + if (unitPriceMinor === null) return fail(t.errors.noPrice, { unitPrice: t.errors.noPrice }); + if (input.savingsAccountId && input.direction !== "debt") + return fail(t.errors.receivablePlan, { savingsAccountId: t.errors.receivablePlan }); + const savings = input.savingsAccountId ? checkAccount(ctx, input.savingsAccountId) : null; + if (input.savingsAccountId && !savings) + return fail(tr.transactions.validation.accountInvalid, { + savingsAccountId: tr.transactions.validation.accountInvalid, + }); + return ok({ + direction: input.direction, + assetKey: input.assetKey, + quantity: input.quantity, + date: input.date, + unitPriceMinor, + valueMinor: valueOf(input.quantity, unitPriceMinor), + note: input.note, + savingsAccountId: savings?.id ?? null, + monthlyPlanMinor: savings ? input.monthlyPlan : null, + }); +} + +export async function createInKind( + actorId: string, + householdId: string, + contactId: string, + input: InKindForm, +): Promise> { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const cid = toObjectId(contactId); + const contact = cid ? contactVisible(ctx, cid) : null; + if (!contact) return fail(tr.common.notFound); + if (contact.archived) return fail(tr.contacts.errors.archived); + const fields = await debtFields(ctx, input); + if (!fields.ok) return fields; + const actor = new Types.ObjectId(actorId); + const doc = await InKindDebtModel.create({ + ...fields.data, + householdId: ctx.m.householdId, + contactId: contact.id, + createdBy: actor, + updatedBy: actor, + }); + return ok({ id: String(doc._id) }); +} + +export async function updateInKind( + actorId: string, + householdId: string, + debtId: string, + input: InKindForm, +): Promise { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const d = await loadDebt(ctx, debtId); + if (!d) return fail(tr.common.notFound); + const paid = d.payments.reduce((a, p) => a + p.quantity, 0); + if (input.quantity < paid) + return fail(t.errors.quantityBelowPaid, { quantity: t.errors.quantityBelowPaid }); + // Ödeme varsa yön ve varlık değişemez (ödemeler o varlık cinsindendir). + if (d.payments.length && (input.direction !== d.direction || input.assetKey !== d.assetKey)) + return fail(t.errors.closed); + const fields = await debtFields(ctx, input); + if (!fields.ok) return fields; + await InKindDebtModel.updateOne( + { _id: d._id, householdId: ctx.m.householdId }, + { + $set: { + ...fields.data, + closedAt: paid >= input.quantity ? (d.closedAt ?? todayIstanbul()) : null, + updatedBy: new Types.ObjectId(actorId), + }, + }, + ); + return ok(undefined); +} + +export async function deleteInKind( + actorId: string, + householdId: string, + debtId: string, +): Promise { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const d = await loadDebt(ctx, debtId); + if (!d) return fail(tr.common.notFound); + const actor = new Types.ObjectId(actorId); + await InKindDebtModel.updateOne( + { _id: d._id }, + { $set: { deletedAt: new Date(), updatedBy: actor } }, + ); + await writeAudit({ + action: "inkind.delete", + actorId: actor, + householdId: ctx.m.householdId, + targetType: "inKindDebt", + targetId: String(d._id), + diff: { assetKey: d.assetKey, quantity: d.quantity, direction: d.direction }, + }); + return ok(undefined); +} + +// --- Birikim ve ödeme --- + +/** Birikim katkısı: kaynak hesaptan borcun birikim hesabına transfer (borç kapanmaz). */ +export async function addContribution( + actorId: string, + householdId: string, + debtId: string, + input: ContributionForm, +): Promise> { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const d = await loadDebt(ctx, debtId); + if (!d) return fail(tr.common.notFound); + if (d.closedAt) return fail(t.errors.closed); + if (d.direction !== "debt" || !d.savingsAccountId) return fail(t.errors.noSavings); + const from = checkAccount(ctx, input.fromAccountId); + const to = checkAccount(ctx, String(d.savingsAccountId)); + if (!from || !to) + return fail(tr.transactions.validation.accountInvalid, { + fromAccountId: tr.transactions.validation.accountInvalid, + }); + if (from.id.equals(to.id)) + return fail(t.errors.sameAccount, { fromAccountId: t.errors.sameAccount }); + const date = istanbulDayStart(input.date); + if (!date) return fail(tr.validation.date, { date: tr.validation.date }); + const actor = new Types.ObjectId(actorId); + const contactName = ctx.contactMap.get(String(d.contactId))?.name ?? ""; + const description = t.contributionDescription( + formatAssetQuantity(d.assetKey, d.quantity), + contactName, + ); + const tx = await TransactionModel.create({ + householdId: ctx.m.householdId, + type: "transfer", + status: "posted", + date, + accountId: from.id, + toAccountId: to.id, + amountMinor: input.amount, + description, + searchText: foldTr(description), + contactId: d.contactId, + inKindDebtId: d._id, + inKindRole: "contribution", + createdBy: actor, + updatedBy: actor, + }); + return ok({ transactionId: String(tx._id) }); +} + +/** + * "Ödeme gerçekleşti": kısmi ödeme miktarı düşürür, tam ödemede kayıt kapanır. TL tutar varsa + * hesaptan çıkış (borç) veya hesaba giriş (alacak) işlemi aynı transaction içinde oluşturulur. + */ +export async function recordPayment( + actorId: string, + householdId: string, + debtId: string, + input: PaymentForm, +): Promise> { + const ctx = await loadContext(actorId, householdId, "member"); + if (!ctx) return fail(tr.common.unauthorized); + const d = await loadDebt(ctx, debtId); + if (!d) return fail(tr.common.notFound); + if (d.closedAt) return fail(t.errors.closed); + if (input.date > todayIstanbul()) return fail(tr.validation.date, { date: tr.validation.date }); + const date = istanbulDayStart(input.date); + if (!date) return fail(tr.validation.date, { date: tr.validation.date }); + + const account = input.accountId ? checkAccount(ctx, input.accountId) : null; + if (input.accountId && !account) + return fail(tr.transactions.validation.accountInvalid, { + accountId: tr.transactions.validation.accountInvalid, + }); + + let quantity = input.quantity; + if (quantity === null) { + const price = valuationPrice( + d.direction, + await priceOn(ctx.m.householdId, d.assetKey, input.date), + ); + if (!price) return fail(t.errors.noCurrentPrice, { quantity: t.errors.noCurrentPrice }); + quantity = quantityForAmount(input.amount ?? 0, price); + } + const paid = d.payments.reduce((a, p) => a + p.quantity, 0); + const remaining = d.quantity - paid; + quantity = Math.min(quantity, remaining); + if (quantity <= 0) return fail(t.validation.quantity, { quantity: t.validation.quantity }); + const closed = paid + quantity >= d.quantity; + + const actor = new Types.ObjectId(actorId); + const amountMinor = input.amount ?? 0; + const contactName = ctx.contactMap.get(String(d.contactId))?.name ?? ""; + const description = t.paymentDescription( + d.direction === "debt", + formatAssetQuantity(d.assetKey, quantity), + contactName, + ); + + await mongoose.connection.transaction(async (session) => { + let transactionId: Types.ObjectId | null = null; + if (amountMinor > 0 && account) { + const out = d.direction === "debt"; + const [tx] = await TransactionModel.create( + [ + { + householdId: ctx.m.householdId, + type: "transfer", + status: "posted", + date, + // Borç ödemesi hesaptan çıkar; alacak tahsilatı hesaba girer. + accountId: out ? account.id : null, + toAccountId: out ? null : account.id, + amountMinor, + description, + searchText: foldTr(description), + contactId: d.contactId, + inKindDebtId: d._id, + inKindRole: "payment", + createdBy: actor, + updatedBy: actor, + }, + ], + { session }, + ); + transactionId = tx!._id; + } + await InKindDebtModel.updateOne( + { _id: d._id, householdId: ctx.m.householdId, closedAt: null }, + { + $push: { + payments: { + date: input.date, + method: input.method, + quantity, + amountMinor, + accountId: account?.id ?? null, + transactionId, + note: input.note, + createdBy: actor, + }, + }, + $set: { closedAt: closed ? input.date : null, updatedBy: actor }, + }, + { session }, + ); + await writeAudit( + { + action: closed ? "inkind.close" : "inkind.payment", + actorId: actor, + householdId: ctx.m.householdId, + targetType: "inKindDebt", + targetId: String(d._id), + diff: { method: input.method, quantity, amountMinor, remaining: remaining - quantity }, + }, + session, + ); + }); + return ok({ closed }); +} + +/** Cari listesi için açık aynî kayıt özetleri (cari kimliği → etiketler). */ +export async function openInKindByContact( + userId: string, + householdId: string, +): Promise> { + const views = (await listInKind(userId, householdId)) ?? []; + const map = new Map(); + for (const v of views) { + if (v.closedAt) continue; + const list = map.get(v.contactId) ?? []; + list.push({ + direction: v.direction, + label: formatAssetQuantity(v.assetKey, v.quantity - v.paidQuantity), + }); + map.set(v.contactId, list); + } + return map; +} diff --git a/src/features/transactions/service.ts b/src/features/transactions/service.ts index 50e4999..ba845e1 100644 --- a/src/features/transactions/service.ts +++ b/src/features/transactions/service.ts @@ -532,6 +532,8 @@ export async function setTransactionDeleted( if (!ctx) return fail(tr.common.unauthorized); const current = await loadOwnTransaction(ctx, transactionId, { includeDeleted: true }); 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); const actor = new Types.ObjectId(actorId); await TransactionModel.updateOne( { _id: current._id, householdId: ctx.m.householdId }, diff --git a/src/features/transactions/statement.ts b/src/features/transactions/statement.ts index 950ddca..14e9eeb 100644 --- a/src/features/transactions/statement.ts +++ b/src/features/transactions/statement.ts @@ -201,8 +201,13 @@ export async function getAccountStatement( const effects = rowsAsc.map((tx) => { const item = toItem(ctx, tx, names); const counterpart = tx.type === "transfer" ? [item.account, item.toAccount] : []; - const hiddenContact = Boolean(item.contact && ctx.contactMap.get(item.contact.id)?.hidden); - if (hiddenContact && item.contact) item.contact.name = tr.transactions.list.hiddenContact; + // Gizli cariye bağlı hareket (cari hareketi veya aynî borç ödemesi): kişi adı ve açıklama gizlenir. + const hiddenContact = Boolean(tx.contactId && ctx.contactMap.get(String(tx.contactId))?.hidden); + if (hiddenContact) { + if (item.contact) item.contact.name = tr.transactions.list.hiddenContact; + item.description = ""; + item.payeeName = tr.transactions.list.hiddenContact; + } const restricted = hiddenContact || counterpart.some((a) => a && ctx.accountMap.get(a.id)?.hidden); if (restricted) { diff --git a/src/models/asset-price.ts b/src/models/asset-price.ts new file mode 100644 index 0000000..70ad866 --- /dev/null +++ b/src/models/asset-price.ts @@ -0,0 +1,29 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { ASSET_KEYS } from "@/shared/assets"; + +/** + * Fiyat tablosu (Bölüm 6.6): varlık × tarih × alış/satış (birim başına kuruş). İlk aşamada elle + * giriş; otomatik kaynaklar Faz 17'de `source` ile eklenir. Gün başına tek kayıt (yeniden girişte + * güncellenir). + */ +const assetPriceSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + assetKey: { type: String, enum: ASSET_KEYS, required: true }, + /** "YYYY-MM-DD" (İstanbul). */ + date: { type: String, required: true }, + buyMinor: { type: Number, required: true, min: 0 }, + sellMinor: { type: Number, required: true, min: 0 }, + source: { type: String, enum: ["manual"], default: "manual" }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + }, + { timestamps: true, collection: "assetPrices" }, +); + +assetPriceSchema.index({ householdId: 1, assetKey: 1, date: -1 }, { unique: true }); + +export type AssetPrice = InferSchemaType; + +export const AssetPriceModel: Model = + (models.AssetPrice as Model | undefined) ?? + model("AssetPrice", assetPriceSchema); diff --git a/src/models/audit-log.ts b/src/models/audit-log.ts index fae2046..730811a 100644 --- a/src/models/audit-log.ts +++ b/src/models/audit-log.ts @@ -30,6 +30,9 @@ export const AUDIT_ACTIONS = [ "recurring.delete", "contact.archive", "contact.unarchive", + "inkind.payment", + "inkind.close", + "inkind.delete", ] as const; export type AuditAction = (typeof AUDIT_ACTIONS)[number]; diff --git a/src/models/in-kind-debt.ts b/src/models/in-kind-debt.ts new file mode 100644 index 0000000..2e4524a --- /dev/null +++ b/src/models/in-kind-debt.ts @@ -0,0 +1,61 @@ +import { model, models, Schema, type InferSchemaType, type Model } from "mongoose"; +import { ASSET_KEYS } from "@/shared/assets"; + +/** + * Aynî borç/alacak (Bölüm 6.5): bir cariyle altın, ziynet veya döviz cinsinden borç. + * Borç tarihi fiyatı ve TL karşılığı not olarak saklanır; güncel değer fiyat tablosundan. + * Kapanış yalnızca "Ödeme gerçekleşti" kayıtlarıyla (kısmi ödeme miktarı düşürür). + * Birikim planı: katkılar seçilen birikim hesabına transferdir (`transactions.inKindDebtId`). + */ +const paymentSchema = new Schema( + { + date: { type: String, required: true }, + /** asset: aynı varlıkla · cash: TL karşılığıyla. */ + method: { type: String, enum: ["asset", "cash"], required: true }, + quantity: { type: Number, required: true, min: 1 }, + /** Ödemede harcanan / tahsil edilen TL (aynı varlıkla ödemede varlığı almak için harcanan). */ + amountMinor: { type: Number, default: 0, min: 0 }, + accountId: { type: Schema.Types.ObjectId, default: null }, + transactionId: { type: Schema.Types.ObjectId, default: null }, + note: { type: String, default: "", maxlength: 300 }, + createdBy: { type: Schema.Types.ObjectId, required: true }, + }, + { _id: true, timestamps: { createdAt: true, updatedAt: false } }, +); + +const inKindDebtSchema = new Schema( + { + householdId: { type: Schema.Types.ObjectId, required: true }, + contactId: { type: Schema.Types.ObjectId, required: true }, + /** debt: ben borçluyum · receivable: bana borçlu. */ + direction: { type: String, enum: ["debt", "receivable"], required: true }, + assetKey: { type: String, enum: ASSET_KEYS, required: true }, + /** Binde bir (5 gr → 5000). */ + quantity: { type: Number, required: true, min: 1 }, + date: { type: String, required: true }, + unitPriceMinor: { type: Number, default: null }, + valueMinor: { type: Number, default: null }, + note: { type: String, default: "", maxlength: 500 }, + savingsAccountId: { type: Schema.Types.ObjectId, default: null }, + monthlyPlanMinor: { type: Number, default: null }, + payments: { type: [paymentSchema], default: [] }, + closedAt: { type: String, 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: "inKindDebts" }, +); + +inKindDebtSchema.index({ householdId: 1, contactId: 1, deletedAt: 1 }); +inKindDebtSchema.index( + { householdId: 1, seedRef: 1 }, + { unique: true, partialFilterExpression: { seedRef: { $type: "string" } } }, +); + +export type InKindDebt = InferSchemaType; + +export const InKindDebtModel: Model = + (models.InKindDebt as Model | undefined) ?? + model("InKindDebt", inKindDebtSchema); diff --git a/src/models/transaction.ts b/src/models/transaction.ts index ff08541..98b0770 100644 --- a/src/models/transaction.ts +++ b/src/models/transaction.ts @@ -58,6 +58,12 @@ const transactionSchema = new Schema( /** Cari transferi: karşı taraf kişi (hesabın biri boş). */ contactId: { type: Schema.Types.ObjectId, default: null }, contactKind: { type: String, enum: [...CONTACT_ENTRY_KINDS, null], default: null }, + /** + * Aynî borca bağlı para hareketi: birikim katkısı (hesap → birikim hesabı) veya ödeme. + * `contactId` görünürlük için doludur ama `contactKind` boştur: cari TL bakiyesine girmez. + */ + inKindDebtId: { type: Schema.Types.ObjectId, default: null }, + inKindRole: { type: String, enum: ["contribution", "payment", null], 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. diff --git a/src/shared/assets.test.ts b/src/shared/assets.test.ts new file mode 100644 index 0000000..596d742 --- /dev/null +++ b/src/shared/assets.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + formatAssetQuantity, + inKindProgress, + quantityForAmount, + valuationPrice, + valueOf, +} from "./assets"; + +describe("varlık hesapları", () => { + it("değer ve miktar karşılığı (binde bir miktar, kuruş fiyat)", () => { + // 5 gr × 3.250,50 TL = 16.252,50 TL + expect(valueOf(5_000, 3_250_50)).toBe(16_252_50); + expect(valueOf(1_500, 100_00)).toBe(150_00); + expect(quantityForAmount(16_252_50, 3_250_50)).toBe(5_000); + expect(quantityForAmount(100, 0)).toBe(0); + }); + + it("biçim", () => { + expect(formatAssetQuantity("gram_gold", 5_000)).toBe("5 gr Gram altın (24 ayar)"); + expect(formatAssetQuantity("quarter_gold", 2_000)).toBe("2 adet Çeyrek altın"); + expect(formatAssetQuantity("usd", 1_500_000)).toBe("1.500 USD"); + expect(formatAssetQuantity("bracelet_22k", 12_345)).toBe("12,345 gr 22 ayar bilezik"); + }); + + it("değerleme fiyatı: borç satış, alacak alış", () => { + const p = { buyMinor: 3_200_00, sellMinor: 3_300_00 }; + expect(valuationPrice("debt", p)).toBe(3_300_00); + expect(valuationPrice("receivable", p)).toBe(3_200_00); + expect(valuationPrice("debt", null)).toBeNull(); + }); +}); + +describe("aynî borç ilerlemesi", () => { + const base = { + quantity: 5_000, + paidQuantity: 0, + unitPriceMinor: 3_000_00, + contributedMinor: 6_000_00, + spentMinor: 0, + monthlyPlanMinor: 2_000_00, + today: "2026-11-05", + }; + + it("kalan ihtiyaç ve tahmini kapanış ayı", () => { + // 5 gr × 3.000 = 15.000; biriken 6.000 → 9.000 kaldı; ayda 2.000 → 5 ay → 2027-04 + expect(inKindProgress(base)).toEqual({ + remainingQuantity: 5_000, + currentValueMinor: 15_000_00, + accumulatedMinor: 6_000_00, + remainingNeedMinor: 9_000_00, + estimatedCloseMonth: "2027-04", + }); + }); + + it("kısmi ödeme, birikim yeterli, fiyat yok, plan yok", () => { + expect(inKindProgress({ ...base, paidQuantity: 3_000 }).remainingNeedMinor).toBe(0); + expect(inKindProgress({ ...base, paidQuantity: 3_000 }).estimatedCloseMonth).toBe("2026-11"); + expect(inKindProgress({ ...base, unitPriceMinor: null }).remainingNeedMinor).toBeNull(); + expect(inKindProgress({ ...base, monthlyPlanMinor: null }).estimatedCloseMonth).toBeNull(); + expect(inKindProgress({ ...base, paidQuantity: 5_000 }).remainingQuantity).toBe(0); + expect(inKindProgress({ ...base, spentMinor: 9_000_00 }).accumulatedMinor).toBe(0); + }); +}); diff --git a/src/shared/assets.ts b/src/shared/assets.ts new file mode 100644 index 0000000..e36d691 --- /dev/null +++ b/src/shared/assets.ts @@ -0,0 +1,118 @@ +import { QUANTITY_SCALE } from "./quantity"; +import { addMonths } from "./recurrence"; + +/** + * Varlık türleri (Bölüm 6.5–6.6): altın, ziynet, gümüş ve döviz. Miktarlar binde bir tamsayı + * (5 gr → 5000, 2 adet çeyrek → 2000). Fiyatlar birim başına kuruş. + * + * Fiyat terimleri kuyumcu/döviz bürosu tarafından: "alış" = kurumun sizden aldığı (düşük), + * "satış" = kurumun size sattığı (yüksek). Faz 0 kararı: varlıklar ve aynî alacaklar ALIŞ, + * aynî borçlar SATIŞ fiyatıyla değerlenir. + */ + +export const ASSET_KEYS = [ + "gram_gold", + "quarter_gold", + "half_gold", + "full_gold", + "republic_gold", + "bracelet_22k", + "silver_gram", + "usd", + "eur", + "gbp", +] as const; +export type AssetKey = (typeof ASSET_KEYS)[number]; + +export type AssetUnit = "gr" | "adet" | "birim"; + +export const ASSETS: Record< + AssetKey, + { label: string; unit: AssetUnit; group: "gold" | "silver" | "fx" } +> = { + gram_gold: { label: "Gram altın (24 ayar)", unit: "gr", group: "gold" }, + quarter_gold: { label: "Çeyrek altın", unit: "adet", group: "gold" }, + half_gold: { label: "Yarım altın", unit: "adet", group: "gold" }, + full_gold: { label: "Tam altın", unit: "adet", group: "gold" }, + republic_gold: { label: "Cumhuriyet (Ata) altını", unit: "adet", group: "gold" }, + bracelet_22k: { label: "22 ayar bilezik", unit: "gr", group: "gold" }, + silver_gram: { label: "Gümüş (gram)", unit: "gr", group: "silver" }, + usd: { label: "ABD doları", unit: "birim", group: "fx" }, + eur: { label: "Euro", unit: "birim", group: "fx" }, + gbp: { label: "İngiliz sterlini", unit: "birim", group: "fx" }, +}; + +const FX_SYMBOL: Partial> = { usd: "USD", eur: "EUR", gbp: "GBP" }; + +/** "5 gr Gram altın (24 ayar)", "2 adet Çeyrek altın", "1.500 USD". */ +export function formatAssetQuantity(key: AssetKey, quantity: number): string { + const n = (quantity / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3 }); + const fx = FX_SYMBOL[key]; + if (fx) return `${n} ${fx}`; + const a = ASSETS[key]; + return `${n} ${a.unit} ${a.label}`; +} + +/** Miktar × birim fiyat (kuruş, yuvarlanır). */ +export function valueOf(quantity: number, unitPriceMinor: number): number { + return Math.round((quantity * unitPriceMinor) / QUANTITY_SCALE); +} + +/** TL tutarın karşılığı olan miktar (binde bir, yuvarlanır). */ +export function quantityForAmount(amountMinor: number, unitPriceMinor: number): number { + if (unitPriceMinor <= 0) return 0; + return Math.round((amountMinor * QUANTITY_SCALE) / unitPriceMinor); +} + +export type InKindDirection = "debt" | "receivable"; + +/** Değerlemede kullanılacak fiyat: borç → satış, alacak → alış. */ +export function valuationPrice( + direction: InKindDirection, + price: { buyMinor: number; sellMinor: number } | null, +): number | null { + if (!price) return null; + return direction === "debt" ? price.sellMinor : price.buyMinor; +} + +export type InKindProgress = { + remainingQuantity: number; + /** Kalan miktarın güncel TL karşılığı (fiyat yoksa null). */ + currentValueMinor: number | null; + /** Birikim planı: biriken − ödemelerde kullanılan. */ + accumulatedMinor: number; + remainingNeedMinor: number | null; + /** Aylık birikimle kalan ihtiyaç kaç ayda kapanır; "YYYY-MM" (plan yoksa null). */ + estimatedCloseMonth: string | null; +}; + +export function inKindProgress(input: { + quantity: number; + paidQuantity: number; + unitPriceMinor: number | null; + contributedMinor: number; + spentMinor: number; + monthlyPlanMinor: number | null; + today: string; +}): InKindProgress { + const remainingQuantity = Math.max(0, input.quantity - input.paidQuantity); + const currentValueMinor = + input.unitPriceMinor === null ? null : valueOf(remainingQuantity, input.unitPriceMinor); + const accumulatedMinor = Math.max(0, input.contributedMinor - input.spentMinor); + const remainingNeedMinor = + currentValueMinor === null ? null : Math.max(0, currentValueMinor - accumulatedMinor); + let estimatedCloseMonth: string | null = null; + if (remainingQuantity === 0) estimatedCloseMonth = null; + else if (remainingNeedMinor === 0) estimatedCloseMonth = input.today.slice(0, 7); + else if (remainingNeedMinor !== null && input.monthlyPlanMinor && input.monthlyPlanMinor > 0) { + const months = Math.ceil(remainingNeedMinor / input.monthlyPlanMinor); + estimatedCloseMonth = addMonths(`${input.today.slice(0, 7)}-01`, months).slice(0, 7); + } + return { + remainingQuantity, + currentValueMinor, + accumulatedMinor, + remainingNeedMinor, + estimatedCloseMonth, + }; +} diff --git a/src/shared/schemas/in-kind.ts b/src/shared/schemas/in-kind.ts new file mode 100644 index 0000000..64ac2e6 --- /dev/null +++ b/src/shared/schemas/in-kind.ts @@ -0,0 +1,116 @@ +import { z } from "zod"; +import { tr } from "../../i18n/tr"; +import { ASSET_KEYS } from "../assets"; +import { parseMoney } from "../money"; +import { parseQuantity } from "../quantity"; + +const v = tr.validation; +const k = tr.inKind.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(), +); + +const quantity = z + .string() + .trim() + .transform((s, ctx) => { + const q = parseQuantity(s); + if (q === null || q <= 0) { + ctx.addIssue({ code: "custom", message: k.quantity }); + return z.NEVER; + } + return q; + }); + +/** Fiyat girişi: satış boşsa alışla aynı (tek fiyat girilirse ikisine de yazılır — Faz 0 kararı). */ +export const assetPriceFormSchema = z + .object({ + assetKey: z.enum(ASSET_KEYS), + date: ymd, + buy: optionalMoney, + sell: optionalMoney, + }) + .superRefine((x, ctx) => { + if (x.buy === null && x.sell === null) + ctx.addIssue({ code: "custom", path: ["buy"], message: k.price }); + }) + .transform((x) => ({ + assetKey: x.assetKey, + date: x.date, + buyMinor: (x.buy ?? x.sell)!, + sellMinor: (x.sell ?? x.buy)!, + })); +export type AssetPriceForm = z.output; + +/** Aynî borç/alacak kaydı. Birim fiyat boşsa fiyat tablosundan (o tarihteki son fiyat) alınır. */ +export const inKindFormSchema = z.object({ + direction: z.enum(["debt", "receivable"]), + assetKey: z.enum(ASSET_KEYS), + quantity, + date: ymd, + unitPrice: optionalMoney, + note: z.string().trim().max(500).default(""), + savingsAccountId: z.preprocess(emptyToNull, objectId.nullable()), + monthlyPlan: optionalMoney, +}); +export type InKindForm = z.output; + +/** Birikim katkısı: hesaptan borcun birikim hesabına transfer. */ +export const contributionSchema = z.object({ + fromAccountId: objectId, + amount: z + .string() + .trim() + .transform((s, ctx) => { + const minor = parseMoney(s); + if (minor === null || minor <= 0) { + ctx.addIssue({ code: "custom", message: tr.transactions.validation.amountPositive }); + return z.NEVER; + } + return minor; + }), + date: ymd, +}); +export type ContributionForm = z.output; + +/** + * "Ödeme gerçekleşti". Aynı varlıkla: miktar zorunlu, TL (varlığı almak için harcanan) isteğe + * bağlı. TL karşılığıyla: tutar ve hesap zorunlu; miktar boşsa güncel fiyattan hesaplanır. + */ +export const paymentFormSchema = z + .object({ + method: z.enum(["asset", "cash"]), + date: ymd, + quantity: z.preprocess(emptyToNull, quantity.nullable()), + amount: optionalMoney, + accountId: z.preprocess(emptyToNull, objectId.nullable()), + note: z.string().trim().max(300).default(""), + }) + .superRefine((x, ctx) => { + if (x.method === "asset" && x.quantity === null) + ctx.addIssue({ code: "custom", path: ["quantity"], message: k.quantity }); + if (x.method === "cash") { + if (!x.amount) ctx.addIssue({ code: "custom", path: ["amount"], message: k.amount }); + if (!x.accountId) ctx.addIssue({ code: "custom", path: ["accountId"], message: k.account }); + } + if (x.amount && !x.accountId) + ctx.addIssue({ code: "custom", path: ["accountId"], message: k.account }); + }); +export type PaymentForm = z.output;