UNPKG

idr-formatting

Version:

Tiny helpers to format and parse Indonesian-style prices (IDR) with '.' thousands and ',' decimals.

1 lines 12.7 kB
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["// src/index.ts\n\n/** Sign type for fixed-point representation */\nexport type IdxSign = 1 | -1\n\n/** Exact fixed-point representation backed by BigInt (no floating-point errors) */\nexport interface FixedIdr {\n /** Sign of the value: 1 or -1 */\n sign: IdxSign\n /** All digits as integer (no decimal point) */\n units: bigint\n /** Number of decimal digits */\n scale: number\n /** Convert to JS Number (may lose precision for very large values) */\n toNumber(): number\n /** Canonical decimal string with \".\" as decimal separator */\n toString(): string\n}\n\nexport interface ParseIdrOptions {\n /** \"number\" (default) -> returns number|null; \"fixed\" -> returns FixedIdr|null */\n mode?: \"number\" | \"fixed\"\n}\n\nexport interface FormatIdrOptions {\n /**\n * Decimal behavior\n * - \"auto\" (default): preserve decimals as typed (no rounding, no padding)\n * - number: force exactly that many decimals (round/pad as needed)\n */\n decimals?: \"auto\" | number\n /**\n * When decimals === \"auto\", pad decimals with zeros to at least 2 digits.\n * Example: \"1050,5\" -> \"1.050,50\"\n */\n padZeros?: boolean\n}\n\n/* ───────────────────────────────\n Helpers\n────────────────────────────────── */\n\n/** Format integer digits with Indonesian thousands separators (string-based, no Number) */\nfunction groupThousandsStr(digits: string): string {\n digits = digits.replace(/^0+(?!$)/, \"\") || \"0\"\n let out = \"\"\n for (let i = digits.length; i > 0; i -= 3) {\n const start = Math.max(0, i - 3)\n const chunk = digits.slice(start, i)\n out = out ? `${chunk}.${out}` : chunk\n }\n return out\n}\n\n/** Apply fixed decimal rounding using BigInt (safe for huge numbers) */\nfunction applyFixedDecimals(intDigits: string, decDigits: string, decimals: number): { i: string; d: string } {\n const cleanI = (intDigits || \"0\").replace(/\\D/g, \"\") || \"0\"\n const cleanD = (decDigits || \"\").replace(/\\D/g, \"\")\n if (decimals <= 0) {\n // round to integer\n const carry = cleanD[0] && +cleanD[0] >= 5 ? 1n : 0n\n const big = BigInt(cleanI) + carry\n return { i: big.toString(), d: \"\" }\n }\n if (cleanD.length === decimals) return { i: cleanI, d: cleanD }\n if (cleanD.length < decimals) return { i: cleanI, d: cleanD.padEnd(decimals, \"0\") }\n\n // cleanD is longer than requested → rounding\n const keep = cleanD.slice(0, decimals)\n const next = cleanD[decimals] ?? \"0\"\n if (+next < 5) return { i: cleanI, d: keep }\n\n // increment integer+decimal as a single BigInt\n const inc = (BigInt(cleanI + keep) + 1n).toString().padStart(cleanI.length + keep.length, \"0\")\n const newI = inc.slice(0, inc.length - decimals) || \"0\"\n const newD = inc.slice(-decimals)\n return { i: newI, d: newD }\n}\n\n/** Keep only the first comma as decimal marker; drop the rest */\nfunction keepFirstCommaOnly(s: string): string {\n const idx = s.indexOf(\",\")\n if (idx < 0) return s\n return s.slice(0, idx + 1) + s.slice(idx + 1).replace(/,/g, \"\")\n}\n\n/** Fixed-point factory */\nfunction makeFixed(sign: IdxSign, intDigits: string, decDigits: string): FixedIdr {\n const i = intDigits.replace(/^0+(?!$)/, \"\") || \"0\"\n const d = decDigits.replace(/[^0-9]/g, \"\")\n const scale = d.length\n const units = BigInt(i + d)\n\n return {\n sign,\n units,\n scale,\n toNumber() {\n const n = Number(units) / Math.pow(10, scale)\n return sign === -1 ? -n : n\n },\n toString() {\n if (scale === 0) return (sign === -1 ? \"-\" : \"\") + units.toString()\n const s = units.toString().padStart(scale + 1, \"0\")\n const head = s.slice(0, s.length - scale)\n const tail = s.slice(-scale)\n return (sign === -1 ? \"-\" : \"\") + head + \".\" + tail\n }\n }\n}\n\nfunction isFixedIdr(v: unknown): v is FixedIdr {\n return !!v && typeof v === \"object\" && \"units\" in (v as any) && \"scale\" in (v as any)\n}\n\n/* ───────────────────────────────\n formatIdr\n────────────────────────────────── */\n\n/**\n * Format a value into Indonesian numeric style:\n * - Thousands: \".\"\n * - Decimals: \",\"\n *\n * Heuristics:\n * - If comma (\",\") exists → it is the decimal separator.\n * - If dot (\".\") exists and no comma:\n * • If pattern looks like thousands (e.g., \"1.500\", \"12.345.678\"),\n * treat \".\" as thousands.\n * • Otherwise, first dot is decimal (e.g., \"12.34\"\"12,34\").\n * - Plain digits are formatted with thousand separators.\n * - Minus sign is preserved.\n *\n * Notes:\n * - Non-digit characters are ignored (currency symbol, letters, spaces).\n * - Decimal digits are preserved as typed unless `decimals` option is set.\n */\nexport function formatIdr(\n value: string | number | FixedIdr | null | undefined,\n options: FormatIdrOptions = {}\n): string {\n if (value == null || value === \"\") return \"\"\n if (isFixedIdr(value)) return formatIdr(value.toString(), options)\n\n const { decimals = \"auto\", padZeros = false } = options\n\n let str = String(value).trim()\n const negative = str.startsWith(\"-\")\n if (negative) str = str.slice(1)\n\n function finish(intDigits: string, rawDecimals?: string): string {\n if (decimals === \"auto\") {\n const iFmt = groupThousandsStr(intDigits)\n if (!rawDecimals) return iFmt\n const dec = padZeros ? rawDecimals.padEnd(2, \"0\") : rawDecimals\n return `${iFmt},${dec}`\n } else {\n const { i, d } = applyFixedDecimals(intDigits, rawDecimals ?? \"\", decimals)\n const iFmt = groupThousandsStr(i)\n return decimals > 0 ? `${iFmt},${d}` : iFmt\n }\n }\n\n let display = \"\"\n\n if (str.includes(\",\")) {\n const only = str.replace(/[^0-9,]/g, \"\")\n const raw = keepFirstCommaOnly(only)\n const [intPart, decPart = \"\"] = raw.split(\",\")\n const intDigits = (intPart || \"\").replace(/\\D/g, \"\") || \"0\"\n const decDigits = (decPart || \"\").replace(/\\D/g, \"\")\n display = finish(intDigits, decDigits)\n } else if (str.includes(\".\")) {\n const s = str.replace(/\\s+/g, \"\")\n const thousandPattern = /^\\d{1,3}(\\.\\d{3})+$/\n if (thousandPattern.test(s)) {\n const digits = s.replace(/\\./g, \"\") || \"0\"\n display = finish(digits)\n } else {\n const [left, right = \"\"] = s.split(\".\", 2)\n const intDigits = (left || \"\").replace(/\\D/g, \"\") || \"0\"\n const decDigits = (right || \"\").replace(/\\D/g, \"\")\n display = finish(intDigits, decDigits)\n }\n } else {\n const digits = str.replace(/\\D/g, \"\") || \"0\"\n display = finish(digits)\n }\n\n return negative ? `-${display}` : display\n}\n\n/* ───────────────────────────────\n parseIdr\n────────────────────────────────── */\n\n/**\n * Parse an Indonesian-formatted string into a JS number or FixedIdr.\n *\n * Rules:\n * - \".\" is thousands separator, removed.\n * - \",\" is decimal, converted to \".\".\n * - Only the first comma is kept as decimal.\n * - Minus sign is preserved.\n * - Returns null for invalid input.\n *\n * Modes:\n * - \"number\" (default): return JS Number (may lose precision).\n * - \"fixed\": return FixedIdr with BigInt units (exact).\n */\nexport function parseIdr(\n str: string | number | null | undefined,\n opts: ParseIdrOptions = {}\n): number | FixedIdr | null {\n const mode = opts.mode ?? \"number\"\n if (str == null || str === \"\") return null\n\n let s = String(str).trim()\n const isNegative = s.startsWith(\"-\")\n if (isNegative) s = s.slice(1)\n\n s = s.replace(/[^0-9.,]/g, \"\")\n if (!s) return null\n\n s = keepFirstCommaOnly(s)\n\n const normalized = s.replace(/\\./g, \"\").replace(\",\", \".\")\n if (normalized === \"\" || normalized === \".\") return null\n\n const [intRaw, decRaw = \"\"] = normalized.split(\".\")\n const intDigits = intRaw.replace(/\\D/g, \"\") || \"0\"\n const decDigits = decRaw.replace(/\\D/g, \"\")\n\n if (mode === \"fixed\") {\n return makeFixed(isNegative ? -1 : 1, intDigits, decDigits)\n } else {\n const num = Number((isNegative ? \"-\" : \"\") + intDigits + (decDigits ? \".\" + decDigits : \"\"))\n return Number.isFinite(num) ? num : null\n }\n}\n"],"mappings":"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","names":[]}