idr-formatting
Version:
Tiny helpers to format and parse Indonesian-style prices (IDR) with '.' thousands and ',' decimals.
277 lines (212 loc) β’ 7.95 kB
Markdown

Tiny helpers to **format** and **parse** Indonesian-style prices (IDR).
- Thousands separator: `.`
- Decimal separator: `,`
- Minus sign is preserved (e.g. `-1050.5 β -1.050,5`)
- Optional **exact** parsing with `BigInt` (no floating-point error)
---
### Live demo
π https://netng.github.io/idr-formatting-example/
---
## Features
- **formatIdr(value, options?)**
- Smart dot/comma heuristics
- Preserves user decimals by default
- Optional `decimals` (force fixed digits) & `padZeros`
- Accepts numbers, strings, or exact `FixedIdr` objects
- **parseIdr(value, options?)**
- Returns a JS **number** by default
- Or returns an exact **FixedIdr** (sign + units + scale) with `{ mode: "fixed" }`
- Ignores stray characters like `Rp` or spaces
- Works in browsers & Node (ESM & CJS)
- Great fit for **Rails (Importmap + Stimulus)**
---
```sh
npm install idr-formatting
yarn add idr-formatting
pnpm add idr-formatting
```
```js
import { formatIdr, parseIdr } from "idr-formatting"
formatIdr("1000") // "1.000"
formatIdr("1050") // "1.050"
formatIdr("1050,32") // "1.050,32"
formatIdr(1050.32) // "1.050,32"
parseIdr("1.050,32") // 1050.32
parseIdr("1.000") // 1000
parseIdr("Rp 1.234,56") // 1234.56
parseIdr(" 10.000 ") // 10000
```
```js
const { formatIdr, parseIdr } = require("idr-formatting")
formatIdr(1000000) // "1.000.000"
parseIdr("1.234,56") // 1234.56
```
`formatIdr(value, options?)`
```js
type FormatIdrOptions = {
/**
* "auto" (default): preserve decimals as typed (no rounding)
* number: force fixed decimals (round/pad as needed)
*/
decimals?: "auto" | number
/**
* If decimals === "auto", pad to at least 2 digits when decimal exists.
* E.g. "1050,5" -> "1.050,50"
*/
padZeros?: boolean
}
```
```js
formatIdr("1050,5") // "1.050,5"
formatIdr("1050,5", { decimals: "auto", padZeros: true }) // "1.050,50"
formatIdr("1050,5678", { decimals: 2 }) // "1.050,57"
formatIdr("1000", { decimals: 2 }) // "1.000,00"
formatIdr(1000, { decimals: 0 }) // "1.000"
```
`parseIdr(value, options?)`
```js
type ParseIdrOptions = {
/** "number" (default) -> number|null; "fixed" -> FixedIdr|null (exact) */
mode?: "number" | "fixed"
}
```
Exact parsing (no floating errors):
```js
const fx = parseIdr("1.234.567,89", { mode: "fixed" })
// => { sign: 1, units: 123456789n, scale: 2, toNumber(), toString() }
fx.toString() // "1234567.89" (exact)
formatIdr(fx) // "1.234.567,89"
```
| Input (raw) | `formatIdr` output | `parseIdr` output |
|--------------------------------|---------------------------|-------------------------------------|
| `"1000"` | `"1.000"` | `1000` |
| `"1050"` | `"1.050"` | `1050` |
| `"1050,32"` | `"1.050,32"` | `1050.32` |
| `1050.32` | `"1.050,32"` | `1050.32` |
| `"1.500"` | `"1.500"` | `1500` |
| `"1.500,55"` | `"1.500,55"` | `1500.55` |
| `"12.34"` | `"12,34"` | `12.34` |
| `"-1050.5"` | `"-1.050,5"` | `-1050.5` |
| `"Rp 1.234,56"` | `"1.234,56"` | `1234.56` |
| `" 10.000 "` | `"10.000"` | `10000` |
| `""` | `""` | `null` |
| `"abc"` | `""` | `null` |
| `"9.223.372.036.854.775.807"` | `"9.223.372.036.854.775.807"` | `9223372036854776000` (approx, due to JS Number limits) |
| `"9.223.372.036.854.775.807,99"` with `{ mode: "fixed" }` | `"9.223.372.036.854.775.807,99"` | `{ sign: 1, units: 922337203685477580799n, scale: 2 }` (exact) |
---
```js
import { parseIdr, formatIdr } from "idr-formatting"
// Normal parse (as Number, precision lost for huge values)
parseIdr("9.223.372.036.854.775.807,99")
// => 9223372036854776000 (approx)
// Exact parse with BigInt-backed FixedIdr
const fx = parseIdr("9.223.372.036.854.775.807,99", { mode: "fixed" })
/*
fx = {
sign: 1,
units: 922337203685477580799n,
scale: 2,
toNumber: [Function],
toString: [Function]
}
*/
fx.toString() // "9223372036854775807.99" (exact decimal string)
formatIdr(fx) // "9.223.372.036.854.775.807,99"
```
This way users see both:
- **Regular `Number` mode** (fast, but limited by JS precision).
- **Fixed mode** (BigInt exact, good for finance & huge numbers).
For typical inputs, formatting and parsing are stable:
```js
parseIdr(formatIdr("1050,32")) // 1050.32
formatIdr(parseIdr("1.050,32")) // "1.050,32"
parseIdr(formatIdr(-1500)) // -1500
formatIdr(parseIdr("-1.500")) // "-1.500"
```
- `parseIdr(formatIdr(x)) === x` for most integers and decimals.
- `formatIdr(parseIdr(x)) === x` for valid Indonesian-formatted strings.
- Trailing decimals are preserved as typed (unless you set `decimals: <number>`).
- Non-formatting characters (`Rp`, spaces) are ignored by parseIdr.
### Limitations
- Decimal length preserved by default (no auto-rounding):
`formatIdr("1234,5678") // "1.234,5678"`.
- No auto currency symbol β add `Rp` in your UI.
- Numbers have IEEE-754 limits. For huge/precise values use `{ mode: "fixed" }` to get `FixedIdr` (BigInt-backed).
- Misplaced minus like `"1.000-"` is not recognized; only leading - is preserved.
```bash
bin/importmap pin idr-formatting@1.x --from jsdelivr
bin/importmap pin idr-formatting@1.x --from unpkg
```
```js
// app/javascript/controllers/idr_price_controller.js
import { Controller } from "@hotwired/stimulus"
import { formatIdr, parseIdr } from "idr-formatting"
export default class extends Controller {
static values = { submitRawTo: String }
connect() {
this.input = this.element
this.
this.format()
}
format() {
const cleaned = this.input.value.replace(/[^0-9,]/g, "")
this.input.value = formatIdr(cleaned)
const parsed = parseIdr(this.input.value) // Number | null
if (this.hidden) this.hidden.value = parsed ?? ""
this.input.setSelectionRange(this.input.value.length, this.input.value.length)
}
if (!this.hasSubmitRawToValue) return
if (!this.hidden) {
const hidden = document.createElement("input")
hidden.type = "hidden"
hidden.name = this.submitRawToValue
this.input.insertAdjacentElement("afterend", hidden)
this.hidden = hidden
}
}
}
```
```erb
<input
type="text"
name="product[price_display]"
data-controller="idr-price"
data-idr-price-submit-raw-to-value="product[price]"
data-action="input->idr-price#format blur->idr-price#format"
/>
```
- User sees: `1.050,32`
- Server receives (hidden): `1050.32`
> Tip: put Rp outside the input (addon/label) so caret position isnβt affected.
## Test & Coverage
```sh
# run tests + coverage
npm run test
# watch mode
npm run test:watch
```
## Contributing
1. Fork & clone
2. npm i
3. npm run test
4. PRs welcome!
## License
MIT