Files
butcem/src/shared/money.ts
T
2026-11-05 02:57:58 +03:00

120 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Para birimi yardımcıları — tutarlar HER ZAMAN tamsayı kuruştur (`amountMinor`).
* 125,50 TL → 12550. Float ile hesap yapılmaz; yuvarlama yalnızca gösterimde.
*
* Ayrıştırma kuralları (tr-TR):
* - Virgül varsa ondalık ayırıcıdır; noktalar ve boşluklar binlik ayırıcıdır: "1.250,50" → 125050.
* - Virgül yoksa ve noktalar geçerli binlik gruplarıysa (1.250 / 12.345.678) binlik ayırıcıdır.
* - Virgül yoksa ve tek nokta 1–2 ondalık içeriyorsa ondalıktır: "12.5" → 1250 (telefon klavyeleri).
* - En fazla 2 ondalık; "₺", "TL", boşluklar ve baştaki +/− kabul edilir. Belirsiz girdi → null.
*/
export const MAX_AMOUNT_MINOR = 100_000_000_000_00; // 100 milyar TL üst sınırı (güvenli tamsayı içinde)
const THOUSANDS_GROUPED = /^\d{1,3}(\.\d{3})+$/;
export function parseMoney(input: string): number | null {
let s = input
.trim()
.replace(/\s| | /g, "")
.replace(/₺|TL$/i, "");
if (!s) return null;
let sign = 1;
if (s.startsWith("-") || s.startsWith("−")) {
sign = -1;
s = s.slice(1);
} else if (s.startsWith("+")) {
s = s.slice(1);
}
s = s.replace(/₺/g, "");
let intPart: string;
let fracPart = "";
if (s.includes(",")) {
const parts = s.split(",");
if (parts.length !== 2) return null;
[intPart, fracPart] = parts as [string, string];
if (intPart.includes(".")) {
if (!THOUSANDS_GROUPED.test(intPart)) return null;
intPart = intPart.replace(/\./g, "");
}
} else if (s.includes(".")) {
if (THOUSANDS_GROUPED.test(s)) {
intPart = s.replace(/\./g, "");
} else {
const parts = s.split(".");
if (parts.length !== 2 || parts[1]!.length > 2) return null;
[intPart, fracPart] = parts as [string, string];
}
} else {
intPart = s;
}
if (intPart === "") intPart = "0";
if (!/^\d+$/.test(intPart) || !/^\d{0,2}$/.test(fracPart)) return null;
const minor = Number(intPart) * 100 + Number(fracPart.padEnd(2, "0"));
if (!Number.isSafeInteger(minor) || minor > MAX_AMOUNT_MINOR) return null;
return sign * minor === 0 ? 0 : sign * minor;
}
/** Form alanı için: 125050 → "1.250,50" (simgesiz). */
export function formatMoneyInput(amountMinor: number): string {
assertMinor(amountMinor);
const sign = amountMinor < 0 ? "-" : "";
const abs = Math.abs(amountMinor);
const int = Math.trunc(abs / 100)
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
const frac = (abs % 100).toString().padStart(2, "0");
return `${sign}${int},${frac}`;
}
export function assertMinor(value: number): void {
if (!Number.isSafeInteger(value)) throw new TypeError(`Kuruş tutarı tamsayı olmalı: ${value}`);
}
/** Kuruş tutarlarını toplar (tamsayı garantili). */
export function sumMoney(values: readonly number[]): number {
let total = 0;
for (const v of values) {
assertMinor(v);
total += v;
}
assertMinor(total);
return total;
}
/**
* Oransal bölme (en büyük kalan yöntemi): kuruş kaybı olmadan `totalMinor`'u ağırlıklara böler.
* Parçaların toplamı her zaman tam olarak `totalMinor`'dur. Taksit ve bölünmüş işlem için.
* Eşit kalanlarda önceki parça önceliklidir (deterministik).
*/
export function allocate(totalMinor: number, weights: readonly number[]): number[] {
assertMinor(totalMinor);
if (weights.length === 0) throw new RangeError("En az bir ağırlık gerekli");
if (weights.some((w) => !Number.isFinite(w) || w < 0))
throw new RangeError("Ağırlıklar negatif olamaz");
const weightSum = weights.reduce((a, b) => a + b, 0);
if (weightSum <= 0) throw new RangeError("Ağırlıkların toplamı sıfırdan büyük olmalı");
const sign = totalMinor < 0 ? -1 : 1;
const abs = Math.abs(totalMinor);
const raw = weights.map((w) => (abs * w) / weightSum);
const parts = raw.map((r) => Math.floor(r));
let remainder = abs - parts.reduce((a, b) => a + b, 0);
const order = raw
.map((r, i) => ({ i, frac: r - Math.floor(r) }))
.sort((a, b) => b.frac - a.frac || a.i - b.i);
for (let k = 0; remainder > 0; k = (k + 1) % order.length, remainder--) parts[order[k]!.i]! += 1;
return parts.map((p) => (p === 0 ? 0 : sign * p));
}
/** Eşit bölme: 100,00 TL / 3 → [3334, 3333, 3333]. */
export function splitEvenly(totalMinor: number, count: number): number[] {
if (!Number.isInteger(count) || count < 1) throw new RangeError("Parça sayısı en az 1 olmalı");
return allocate(
totalMinor,
Array.from({ length: count }, () => 1),
);
}