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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user