feat(in-kind): varlık türleri, fiyat tablosu, aynî borç/alacak, birikim planı ve ödeme akışı, 008 migration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 12:37:48 +03:00
parent 1062b2d96c
commit 9781cd9ed2
15 changed files with 1354 additions and 3 deletions
+18
View File
@@ -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();
},
};
+2 -1
View File
@@ -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";
+3
View File
@@ -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,
})
+82
View File
@@ -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<string, string> = {};
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<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) 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<ActionResult<unknown>> {
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));
}
+270
View File
@@ -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<ReturnType<typeof setupSeededDb>>;
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);
});
});
+573
View File
@@ -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<PriceQuote | null> {
const p = await AssetPriceModel.findOne({ householdId, assetKey, date: { $lte: date } })
.sort({ date: -1 })
.lean();
return p ? { buyMinor: p.buyMinor, sellMinor: p.sellMinor, date: p.date } : null;
}
export type PriceRow = { assetKey: AssetKey; latest: PriceQuote | null; history: PriceQuote[] };
export 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<ActionResult> {
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<string, { contributed: number }>();
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<InKindView[]> {
const today = todayIstanbul();
const flows = await savingsFlows(docs.map((d) => d._id));
const priceCache = new Map<string, PriceQuote | null>();
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<InKindView[] | null> {
const ctx = await loadContext(userId, householdId, "viewer");
if (!ctx) return null;
const filter: Record<string, unknown> = { 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<DebtDoc[]>();
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<DebtDoc>();
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<ActionResult<{ id: string }>> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult<{ transactionId: string }>> {
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<ActionResult<{ closed: boolean }>> {
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<Map<string, { direction: InKindDirection; label: string }[]>> {
const views = (await listInKind(userId, householdId)) ?? [];
const map = new Map<string, { direction: InKindDirection; label: string }[]>();
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;
}
+2
View File
@@ -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 },
+7 -2
View File
@@ -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) {
+29
View File
@@ -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<typeof assetPriceSchema>;
export const AssetPriceModel: Model<AssetPrice> =
(models.AssetPrice as Model<AssetPrice> | undefined) ??
model<AssetPrice>("AssetPrice", assetPriceSchema);
+3
View File
@@ -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];
+61
View File
@@ -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<typeof inKindDebtSchema>;
export const InKindDebtModel: Model<InKindDebt> =
(models.InKindDebt as Model<InKindDebt> | undefined) ??
model<InKindDebt>("InKindDebt", inKindDebtSchema);
+6
View File
@@ -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.
+64
View File
@@ -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);
});
});
+118
View File
@@ -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<Record<AssetKey, string>> = { 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,
};
}
+116
View File
@@ -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<typeof assetPriceFormSchema>;
/** 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<typeof inKindFormSchema>;
/** 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<typeof contributionSchema>;
/**
* "Ö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<typeof paymentFormSchema>;