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
+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>;