Currency
Comprehensive utilities for working with Indonesian Rupiah (IDR) currency.
Overview
The Currency module provides type-safe utilities for formatting, parsing, and converting Indonesian Rupiah. Built with zero dependencies and designed to handle common Indonesian business scenarios including invoicing, e-commerce, and financial reports.
Features
- Formatting: Display Rupiah with proper separators and symbols
- Compact Format: Indonesian-style abbreviations (ribu, juta, miliar, triliun)
- Parsing: Convert formatted strings back to numbers
- Terbilang: Convert numbers to Indonesian words (for checks, invoices, receipts)
- Rounding: Clean currency amounts for display
- Split Amounts: Divide amounts into equal or custom-ratio parts
- Validation: Check if a string is a valid Rupiah format
- Calculations: Percentage, difference, and tax calculations
- Flexible: Customizable separators, precision, and display options
Why This Module?
Indonesian Rupiah formatting has unique requirements:
- Uses
.(dot) for thousands separator (not comma) - Uses
,(comma) for decimal separator (not dot) - Has specific compact format units: ribu, juta, miliar, triliun
- Requires “terbilang” (number-to-words) for legal documents
- Follows Indonesian grammar rules for number pronunciation
Installation
npm
npm install @indodev/toolkitQuick Start
import { formatRupiah, formatCompact, toWords, parseRupiah, splitAmount, percentageOf, difference, validateRupiah } from '@indodev/toolkit/currency';
// Format currency
formatRupiah(1500000);
// 'Rp 1.500.000'
// Negative format (standard accounting)
formatRupiah(-1500000);
// '-Rp 1.500.000'
// Compact format
formatCompact(1500000);
// 'Rp 1,5 juta'
// Compact without symbol
formatCompact(1500000, { symbol: false });
// '1,5 juta'
// Convert to words (terbilang)
toWords(1500000);
// 'satu juta lima ratus ribu rupiah'
// Terbilang with decimals
toWords(1500000.5, { withDecimals: true });
// 'satu juta lima ratus ribu rupiah koma lima puluh'
// Parse back to number
parseRupiah('Rp 1.500.000');
// 1500000
// Accounting format (parentheses for negatives)
formatAccounting(-1500000);
// '(Rp 1.500.000)'
// Calculate tax (rate required)
calculateTax(1000000, 0.11);
// 110000
// Split amount (equal)
splitAmount(1500000, 3);
// [500000, 500000, 500000]
// Split amount (custom ratios)
splitAmount(1000000, 2, { ratios: [70, 30] });
// [700000, 300000]
// Percentage calculation
percentageOf(150000, 1000000);
// 15
// Difference calculation
difference(1200000, 1000000);
// { absolute: 200000, percentage: 20, direction: 'increase' }
// Validate Rupiah format
validateRupiah('Rp 1.500.000');
// trueAPI Reference
formatRupiah()
Formats a number as Indonesian Rupiah currency with customizable options.
Type Signature:
function formatRupiah(amount: number, options?: RupiahOptions): string;Parameters:
| Name | Type | Description |
|---|---|---|
amount | number | The amount to format |
options | RupiahOptions | Optional formatting configuration |
Returns:
string - Formatted Rupiah string
Options:
interface RupiahOptions {
symbol?: boolean; // Show 'Rp' symbol (default: true)
decimal?: boolean; // Show decimal places (default: false)
separator?: string; // Thousands separator (default: '.')
decimalSeparator?: string; // Decimal separator (default: ',')
precision?: number; // Decimal places (default: 0 or 2)
spaceAfterSymbol?: boolean; // Space after 'Rp' (default: true)
}Examples:
// Basic formatting
formatRupiah(1500000);
// 'Rp 1.500.000'
formatRupiah(999);
// 'Rp 999'
formatRupiah(0);
// 'Rp 0'
// With decimals
formatRupiah(1500000.5, { decimal: true });
// 'Rp 1.500.000,50'
formatRupiah(1500000.5, { decimal: true, precision: 1 });
// 'Rp 1.500.000,5'
formatRupiah(1500000, { decimal: true, precision: 2 });
// 'Rp 1.500.000,00'
// Without symbol
formatRupiah(1500000, { symbol: false });
// '1.500.000'
formatRupiah(1500000.5, { symbol: false, decimal: true });
// '1.500.000,50'
// Custom separators (international format)
formatRupiah(1500000, { separator: ',', decimalSeparator: '.' });
// 'Rp 1,500,000'
formatRupiah(1500000.5, {
decimal: true,
separator: ',',
decimalSeparator: '.',
});
// 'Rp 1,500,000.50'
// No space after symbol
formatRupiah(1500000, { spaceAfterSymbol: false });
// 'Rp1.500.000'
// Negative numbers
formatRupiah(-1500000);
// 'Rp -1.500.000'
formatRupiah(-1500000, { symbol: false });
// '-1.500.000'
// Large numbers
formatRupiah(1234567890);
// 'Rp 1.234.567.890'
formatRupiah(1234567890.12, { decimal: true });
// 'Rp 1.234.567.890,12'Use Cases:
// E-commerce product display
function ProductPrice({ price }: { price: number }) {
return (
<div>
<span className="currency">
{formatRupiah(price)}
</span>
</div>
)
}
// Invoice line items
function InvoiceItem({ item }) {
return (
<tr>
<td>{item.name}</td>
<td>{item.quantity}</td>
<td>{formatRupiah(item.price, { symbol: false })}</td>
<td>{formatRupiah(item.total)}</td>
</tr>
)
}
// Payment summary with decimals
function PaymentSummary({ amount }) {
const tax = amount * 0.11 // 11% PPN
const total = amount + tax
return (
<div>
<div>Subtotal: {formatRupiah(amount, { decimal: true })}</div>
<div>PPN 11%: {formatRupiah(tax, { decimal: true })}</div>
<div>Total: {formatRupiah(total, { decimal: true })}</div>
</div>
)
}
// International format for exports
function ExportData(amounts: number[]) {
return amounts.map(amount => ({
amount: formatRupiah(amount, {
separator: ',',
decimalSeparator: '.',
decimal: true
})
}))
}formatAccounting()
Formats a number using accounting notation, where negative numbers are enclosed in parentheses.
Type Signature:
function formatAccounting(amount: number, options?: RupiahOptions): string;Parameters:
| Name | Type | Description |
|---|---|---|
amount | number | The amount to format |
options | RupiahOptions | Optional formatting configuration |
Returns:
string - Formatted accounting string.
Examples:
formatAccounting(1500000); // 'Rp 1.500.000'
formatAccounting(-1500000); // '(Rp 1.500.000)'
formatAccounting(-1500, { symbol: false }); // '(1.500)'addRupiahSymbol()
Adds the Rupiah symbol (‘Rp ’) to a string if it’s not already present.
Type Signature:
function addRupiahSymbol(amount: string | number): string;Parameters:
| Name | Type | Description |
| -------- | ------- | ----------- | ------------------------------ |
| amount | string | number | The value to add the symbol to |
Returns:
string - String with ‘Rp ’ prefix.
Examples:
addRupiahSymbol('1.500.000'); // 'Rp 1.500.000'
addRupiahSymbol(1500000); // 'Rp 1500000'formatCompact()
Formats numbers using Indonesian compact notation with proper grammar rules.
Type Signature:
function formatCompact(amount: number): string;Parameters:
| Name | Type | Description |
|---|---|---|
amount | number | The amount to format |
Returns:
string - Compact formatted string with Indonesian units
Compact Units:
| Unit | Value | English Equivalent |
|---|---|---|
| ribu | 1,000 | thousand |
| juta | 1,000,000 | million |
| miliar | 1,000,000,000 | billion |
| triliun | 1,000,000,000,000 | trillion |
Grammar Rules:
The function follows proper Indonesian grammar:
1 jutanot1,0 juta(removes trailing .0)500 ribunot0,5 juta(uses appropriate unit)- Numbers below 100,000 use standard formatting
Examples:
// Millions (juta)
formatCompact(1000000);
// 'Rp 1 juta'
formatCompact(1500000);
// 'Rp 1,5 juta'
formatCompact(2300000);
// 'Rp 2,3 juta'
// Thousands (ribu)
formatCompact(500000);
// 'Rp 500 ribu'
formatCompact(750000);
// 'Rp 750 ribu'
// Below 100k: standard formatting
formatCompact(99000);
// 'Rp 99.000'
formatCompact(1500);
// 'Rp 1.500'
// Billions (miliar)
formatCompact(1000000000);
// 'Rp 1 miliar'
formatCompact(2500000000);
// 'Rp 2,5 miliar'
// Trillions (triliun)
formatCompact(1000000000000);
// 'Rp 1 triliun'
formatCompact(1500000000000);
// 'Rp 1,5 triliun'
// Negative numbers
formatCompact(-1500000);
// 'Rp -1,5 juta'
// Grammar: proper rounding
formatCompact(1000000);
// 'Rp 1 juta' (not '1,0 juta')
formatCompact(1100000);
// 'Rp 1,1 juta'Use Cases:
// Dashboard statistics
function StatCard({ label, value }: { label: string, value: number }) {
return (
<div className="stat-card">
<div className="label">{label}</div>
<div className="value">{formatCompact(value)}</div>
</div>
)
}
// Budget display
function BudgetOverview({ budget }) {
return (
<div>
<h3>Total Budget</h3>
<p className="large">{formatCompact(budget.total)}</p>
<p>Allocated: {formatCompact(budget.allocated)}</p>
<p>Remaining: {formatCompact(budget.remaining)}</p>
</div>
)
}
// Social media friendly numbers
function ShareButton({ views, shares }: { views: number, shares: number }) {
return (
<button>
Share this post
<span>{formatCompact(views)} views</span>
<span>{formatCompact(shares)} shares</span>
</button>
)
}
// Chart labels
function ChartLabel({ value }: { value: number }) {
// More readable than full numbers
return <text>{formatCompact(value)}</text>
}Grammar Note: The function automatically handles Indonesian grammar rules. Numbers are rounded to one decimal place, and trailing zeros are removed (e.g., “1 juta” instead of “1,0 juta”).
parseRupiah()
Parses formatted Rupiah strings back to numeric values. Handles multiple formats including standard, compact, and international notation.
Type Signature:
function parseRupiah(formatted: string): number | null;Parameters:
| Name | Type | Description |
|---|---|---|
formatted | string | The formatted Rupiah string to parse |
Returns:
number | null - Parsed number, or null if invalid
Supported Formats:
- Standard:
"Rp 1.500.000" - No symbol:
"1.500.000" - With decimals:
"Rp 1.500.000,50" - Compact:
"Rp 1,5 juta","Rp 500 ribu" - International:
"Rp 1,500,000.50" - Negative:
"Rp -1.500.000"
Examples:
// Standard format
parseRupiah('Rp 1.500.000');
// 1500000
parseRupiah('1.500.000');
// 1500000
// With decimals
parseRupiah('Rp 1.500.000,50');
// 1500000.5
parseRupiah('Rp 1.500.000,99');
// 1500000.99
// Compact formats
parseRupiah('Rp 1,5 juta');
// 1500000
parseRupiah('Rp 500 ribu');
// 500000
parseRupiah('Rp 2,5 miliar');
// 2500000000
parseRupiah('Rp 1 triliun');
// 1000000000000
// International format
parseRupiah('Rp 1,500,000');
// 1500000
parseRupiah('Rp 1,500,000.50');
// 1500000.5
// Negative numbers
parseRupiah('Rp -1.500.000');
// -1500000
parseRupiah('-1.500.000');
// -1500000
// Without 'Rp' symbol
parseRupiah('1500000');
// 1500000
parseRupiah('1.500.000');
// 1500000
// Invalid input
parseRupiah('invalid');
// null
parseRupiah('');
// null
parseRupiah('abc');
// null
// Edge cases
parseRupiah('Rp 0');
// 0
parseRupiah('0');
// 0Use Cases:
// Parse user input from forms
function handlePriceInput(input: string) {
const amount = parseRupiah(input)
if (amount === null) {
return { error: 'Invalid price format' }
}
return { amount }
}
// Import data from CSV/Excel
function importPrices(csvData: string[][]) {
return csvData.map(row => ({
name: row[0],
price: parseRupiah(row[1]) ?? 0
}))
}
// Calculate totals from formatted strings
function calculateTotal(items: { price: string }[]) {
return items.reduce((sum, item) => {
const amount = parseRupiah(item.price) ?? 0
return sum + amount
}, 0)
}
// Validate and convert user input
function PriceInput({ value, onChange }) {
const [displayValue, setDisplayValue] = useState(value)
const handleChange = (input: string) => {
// Allow user to type freely
setDisplayValue(input)
// Parse and notify parent if valid
const parsed = parseRupiah(input)
if (parsed !== null) {
onChange(parsed)
}
}
const handleBlur = () => {
// Format on blur
const parsed = parseRupiah(displayValue)
if (parsed !== null) {
setDisplayValue(formatRupiah(parsed))
}
}
return (
<input
value={displayValue}
onChange={e => handleChange(e.target.value)}
onBlur={handleBlur}
/>
)
}
// Parse mixed format data
function normalizeAmounts(amounts: string[]) {
return amounts
.map(parseRupiah)
.filter((amount): amount is number => amount !== null)
}Smart Parsing: The function automatically detects the format (Indonesian vs International) based on separator positions and handles both correctly.
toWords()
Converts numbers to Indonesian words (terbilang) following proper Indonesian grammar rules. Essential for checks, invoices, and legal documents.
Type Signature:
function toWords(amount: number, options?: WordOptions): string;Parameters:
| Name | Type | Description |
|---|---|---|
amount | number | The number to convert (supports up to trillions) |
options | WordOptions | Optional conversion settings |
Returns:
string - Indonesian words representation
Options:
interface WordOptions {
uppercase?: boolean; // Capitalize first letter (default: false)
withCurrency?: boolean; // Add 'rupiah' suffix (default: true)
}Indonesian Grammar Rules:
The function implements proper Indonesian number pronunciation:
100= “seratus” (not “satu ratus”)1000= “seribu” (not “satu ribu”)11= “sebelas” (not “satu belas”)- Proper spacing and word order
Examples:
// Basic numbers
toWords(0);
// 'nol rupiah'
toWords(1);
// 'satu rupiah'
toWords(10);
// 'sepuluh rupiah'
toWords(11);
// 'sebelas rupiah'
toWords(100);
// 'seratus rupiah'
toWords(1000);
// 'seribu rupiah'
// Complex numbers
toWords(123);
// 'seratus dua puluh tiga rupiah'
toWords(1500);
// 'seribu lima ratus rupiah'
toWords(12345);
// 'dua belas ribu tiga ratus empat puluh lima rupiah'
// Large numbers
toWords(1000000);
// 'satu juta rupiah'
toWords(1500000);
// 'satu juta lima ratus ribu rupiah'
toWords(2345678);
// 'dua juta tiga ratus empat puluh lima ribu enam ratus tujuh puluh delapan rupiah'
// Billions
toWords(1000000000);
// 'satu miliar rupiah'
toWords(1500000000);
// 'satu miliar lima ratus juta rupiah'
// Trillions
toWords(1000000000000);
// 'satu triliun rupiah'
// With options: uppercase
toWords(1500000, { uppercase: true });
// 'Satu juta lima ratus ribu rupiah'
// With options: no currency
toWords(1500000, { withCurrency: false });
// 'satu juta lima ratus ribu'
toWords(1500000, { uppercase: true, withCurrency: false });
// 'Satu juta lima ratus ribu'
// Negative numbers
toWords(-1500000);
// 'minus satu juta lima ratus ribu rupiah'
// Edge cases
toWords(99999999);
// 'sembilan puluh sembilan juta sembilan ratus sembilan puluh sembilan ribu sembilan ratus sembilan puluh sembilan rupiah'Use Cases:
// Invoice terbilang
function InvoiceTotal({ total }: { total: number }) {
return (
<div className="invoice-total">
<div className="amount">
Total: {formatRupiah(total)}
</div>
<div className="words">
Terbilang: {toWords(total, { uppercase: true })}
</div>
</div>
)
}
// Check printing
function PrintCheck({ amount, recipient }: { amount: number, recipient: string }) {
const words = toWords(amount, { uppercase: true })
return (
<div className="check">
<div>Pay to: {recipient}</div>
<div>Amount: {formatRupiah(amount)}</div>
<div>({words})</div>
</div>
)
}
// Receipt generator
function Receipt({ items, total }) {
return (
<div className="receipt">
{items.map(item => (
<div key={item.id}>
<span>{item.name}</span>
<span>{formatRupiah(item.price)}</span>
</div>
))}
<div className="total">
<div>Total: {formatRupiah(total)}</div>
<div className="terbilang">
{toWords(total, { uppercase: true })}
</div>
</div>
</div>
)
}
// Legal document amount
function ContractAmount({ amount }: { amount: number }) {
return (
<p>
Jumlah kontrak sebesar {formatRupiah(amount)} ({toWords(amount)}).
</p>
)
}
// Quantity descriptions (without currency)
function QuantityInWords({ quantity }: { quantity: number }) {
return (
<span>
{quantity} ({toWords(quantity, { withCurrency: false })}) unit
</span>
)
}Note: The function converts the integer part only. Decimal values are floored before conversion. For financial documents, format decimals separately or round to whole numbers first.
roundToClean()
Rounds currency amounts to clean, readable values. Useful for budgets, estimates, and approximate displays.
Type Signature:
function roundToClean(amount: number, unit?: RoundUnit): number;Parameters:
| Name | Type | Default | Description |
|---|---|---|---|
amount | number | - | The amount to round |
unit | RoundUnit | 'ribu' | The unit to round to |
Round Units:
type RoundUnit = 'ribu' | 'ratus-ribu' | 'juta';
// Values:
// 'ribu' = 1,000
// 'ratus-ribu' = 100,000
// 'juta' = 1,000,000Examples:
// Round to thousands (ribu)
roundToClean(1234567);
// 1235000
roundToClean(1234567, 'ribu');
// 1235000
roundToClean(1234, 'ribu');
// 1000
roundToClean(1567, 'ribu');
// 2000
// Round to hundred thousands (ratus-ribu)
roundToClean(1234567, 'ratus-ribu');
// 1200000
roundToClean(1567890, 'ratus-ribu');
// 1600000
roundToClean(45000, 'ratus-ribu');
// 0
roundToClean(87654, 'ratus-ribu');
// 100000
// Round to millions (juta)
roundToClean(1234567, 'juta');
// 1000000
roundToClean(1567890, 'juta');
// 2000000
roundToClean(456789, 'juta');
// 0
// Negative numbers
roundToClean(-1234567, 'ribu');
// -1235000
roundToClean(-1234567, 'juta');
// -1000000
// Edge cases
roundToClean(0);
// 0
roundToClean(500, 'ribu');
// 1000 (rounds up at midpoint)Use Cases:
// Budget estimates
function BudgetEstimate({ rawAmount }: { rawAmount: number }) {
const rounded = roundToClean(rawAmount, 'juta')
return (
<div>
<div>Estimated Budget</div>
<div>{formatCompact(rounded)}</div>
<small>Rounded from {formatRupiah(rawAmount)}</small>
</div>
)
}
// Price ranges
function PriceRange({ min, max }: { min: number, max: number }) {
const roundedMin = roundToClean(min, 'ratus-ribu')
const roundedMax = roundToClean(max, 'ratus-ribu')
return (
<div>
{formatCompact(roundedMin)} - {formatCompact(roundedMax)}
</div>
)
}
// Approximate totals
function ApproximateTotal({ items }) {
const exact = items.reduce((sum, item) => sum + item.price, 0)
const approx = roundToClean(exact, 'ribu')
return (
<div>
<div>Approximately {formatCompact(approx)}</div>
<small>Exact: {formatRupiah(exact)}</small>
</div>
)
}
// Salary range display
function SalaryRange({ min, max }: { min: number, max: number }) {
return (
<div className="salary-range">
{formatCompact(roundToClean(min, 'juta'))} - {formatCompact(roundToClean(max, 'juta'))}
</div>
)
}
// Budget allocation with rounding
function allocateBudget(total: number, percentages: number[]) {
return percentages.map(pct => {
const amount = total * (pct / 100)
return roundToClean(amount, 'ratus-ribu')
})
}Rounding Behavior: Uses standard rounding (0.5 rounds up). For financial calculations, always round after calculations, not before.
calculateTax()
Calculates tax (PPN) from a given amount and rate.
Type Signature:
function calculateTax(amount: number, rate: number): number;Parameters:
| Name | Type | Description |
|---|---|---|
amount | number | The base amount |
rate | number | The tax rate (e.g., 0.11 for 11% PPN) |
Returns:
number - The calculated tax amount.
Examples:
calculateTax(1000000, 0.11); // 110000
calculateTax(500000, 0.10); // 50000
calculateTax(0, 0.11); // 0
calculateTax(-1000000, 0.11);// -110000 (tax credit)Breaking Change (v0.3.5): The rate parameter is now required. The previous default of 0.11 was removed because tax rates can change over time and violate the Data Independence mandate.
splitAmount()
Splits an amount into equal or custom-ratio parts. Useful for split bills, installments, and budget allocation.
Type Signature:
function splitAmount(amount: number, parts: number, options?: SplitOptions): number[];Parameters:
| Name | Type | Description |
|---|---|---|
amount | number | The amount to split |
parts | number | Number of parts to split into |
options | SplitOptions | Optional split configuration |
Returns:
number[] - Array of split amounts
SplitOptions:
interface SplitOptions {
ratios?: number[]; // Custom percentage ratios (must sum to 100)
roundTo?: RoundUnit; // Round each part to clean amount
}Examples:
// Equal split
splitAmount(1500000, 3);
// [500000, 500000, 500000]
// Remainder distributed to first parts
splitAmount(10000, 3);
// [3334, 3333, 3333]
// Custom ratios
splitAmount(1000000, 2, { ratios: [70, 30] });
// [700000, 300000]
// With rounding
splitAmount(1234567, 3, { roundTo: 'ribu' });
// [412000, 412000, 412000]
// Negative amounts
splitAmount(-1500000, 3);
// [-500000, -500000, -500000]
// Single part
splitAmount(1000000, 1);
// [1000000]Errors:
- Throws
InvalidSplitErrorifparts < 1 - Throws
InvalidSplitErrorifratioslength doesn’t matchparts - Throws
InvalidSplitErrorifratiosdon’t sum to 100
percentageOf()
Calculates what percentage a part is of a total.
Type Signature:
function percentageOf(part: number, total: number): number;Parameters:
| Name | Type | Description |
|---|---|---|
part | number | The part value |
total | number | The total value |
Returns:
number - Percentage (e.g., 15 for 15%)
Examples:
percentageOf(150000, 1000000); // 15
percentageOf(500000, 1000000); // 50
percentageOf(0, 1000000); // 0
percentageOf(1000000, 1000000); // 100
percentageOf(1500000, 1000000); // 150 (over 100%)
percentageOf(100, 0); // 0 (not NaN)
percentageOf(-200000, 1000000); // -20
percentageOf(-200000, -1000000);// 20 (signs cancel)difference()
Calculates absolute and percentage difference between two amounts with direction tracking.
Type Signature:
function difference(amount1: number, amount2: number): {
absolute: number;
percentage: number | null;
direction: 'increase' | 'decrease' | 'same';
};Parameters:
| Name | Type | Description |
|---|---|---|
amount1 | number | The new/current amount |
amount2 | number | The original/reference amount |
Returns:
Object with absolute, percentage, and direction
Examples:
difference(1200000, 1000000);
// { absolute: 200000, percentage: 20, direction: 'increase' }
difference(800000, 1000000);
// { absolute: -200000, percentage: -20, direction: 'decrease' }
difference(1000000, 1000000);
// { absolute: 0, percentage: 0, direction: 'same' }
difference(0, 1000000);
// { absolute: -1000000, percentage: null, direction: 'decrease' }
difference(0, 0);
// { absolute: 0, percentage: 0, direction: 'same' }validateRupiah()
Checks if a string is a valid Rupiah format.
Type Signature:
function validateRupiah(formatted: string): boolean;Parameters:
| Name | Type | Description |
|---|---|---|
formatted | string | The string to validate |
Returns:
boolean - true if valid Rupiah format, false otherwise
Examples:
// Valid formats
validateRupiah('Rp 1.500.000'); // true
validateRupiah('1.500.000'); // true
validateRupiah('Rp 1,5 juta'); // true
validateRupiah('-Rp 1.500.000'); // true
validateRupiah('Rp -1.500.000'); // true (legacy format)
validateRupiah(' Rp 1.500.000 ');// true (trimmed)
// Invalid formats
validateRupiah('abc'); // false
validateRupiah(''); // false
validateRupiah('Rp abc'); // false
validateRupiah('1.500.000abc'); // false
validateRupiah(' '); // false
validateRupiah('Rp'); // falsetoWords() — Decimal Support (Enhanced)
The toWords() function now supports decimal terbilang via the withDecimals option.
Updated Type Signature:
function toWords(amount: number, options?: WordOptions): string;New Option:
interface WordOptions {
uppercase?: boolean;
withCurrency?: boolean;
withDecimals?: boolean; // NEW (default: false)
}Examples:
// Default (no decimals)
toWords(1500000.5);
// 'satu juta lima ratus ribu rupiah'
// With decimals
toWords(1500000.5, { withDecimals: true });
// 'satu juta lima ratus ribu rupiah koma lima puluh'
toWords(1234.56, { withDecimals: true });
// 'seribu dua ratus tiga puluh empat rupiah koma lima enam'
toWords(1500000.5, { withDecimals: true, withCurrency: false });
// 'satu juta lima ratus ribu koma lima puluh'
// Negative with decimals
toWords(-1500000.5, { withDecimals: true });
// 'minus satu juta lima ratus ribu rupiah koma lima puluh'
// Zero decimal part (no decimal words)
toWords(1500000.0, { withDecimals: true });
// 'satu juta lima ratus ribu rupiah'formatCompact() — Enhanced with Options
The formatCompact() function now accepts CompactOptions for customization.
Updated Type Signature:
function formatCompact(amount: number, options?: CompactOptions): string;CompactOptions:
interface CompactOptions {
symbol?: boolean; // Show 'Rp' (default: true)
spaceAfterSymbol?: boolean; // Space after 'Rp' (default: true)
}Examples:
// Default
formatCompact(1500000);
// 'Rp 1,5 juta'
// Without symbol
formatCompact(1500000, { symbol: false });
// '1,5 juta'
// Without space
formatCompact(1500000, { spaceAfterSymbol: false });
// 'Rp1,5 juta'
// Negative (new format)
formatCompact(-1500000);
// '-Rp 1,5 juta'
formatCompact(-1500000, { symbol: false });
// '-1,5 juta'Type Reference
RupiahOptions
Configuration options for formatting Rupiah currency.
interface RupiahOptions {
symbol?: boolean; // Show 'Rp' symbol (default: true)
decimal?: boolean; // Show decimal places (default: false)
separator?: string; // Thousands separator (default: '.')
decimalSeparator?: string; // Decimal separator (default: ',')
precision?: number; // Decimal places (default: 0 or 2)
spaceAfterSymbol?: boolean; // Space after 'Rp' (default: true)
}Default Values:
symbol:true- Shows “Rp” prefixdecimal:false- No decimal placesseparator:'.'- Indonesian standarddecimalSeparator:','- Indonesian standardprecision:0(or2if decimal=true)spaceAfterSymbol:true- “Rp 1.000” not “Rp1.000”
WordOptions
Options for converting numbers to Indonesian words.
interface WordOptions {
uppercase?: boolean; // Capitalize first letter (default: false)
withCurrency?: boolean; // Add 'rupiah' suffix (default: true)
withDecimals?: boolean; // Include decimal words (default: false)
}Usage:
uppercase: For formal documents (invoices, contracts)withCurrency: Omit when describing quantities, not moneywithDecimals: Include decimal terbilang with “koma” separator
CompactOptions
Options for compact currency formatting.
interface CompactOptions {
symbol?: boolean; // Show 'Rp' (default: true)
spaceAfterSymbol?: boolean;// Space after 'Rp' (default: true)
}SplitOptions
Options for splitting amounts.
interface SplitOptions {
ratios?: number[]; // Custom percentage ratios (must sum to 100)
roundTo?: RoundUnit; // Round each part to clean amount
}RoundUnit
Units for rounding currency amounts.
type RoundUnit = 'ribu' | 'ratus-ribu' | 'juta';Values:
'ribu': 1,000 (thousands)'ratus-ribu': 100,000 (hundred thousands)'juta': 1,000,000 (millions)
Common Use Cases
E-commerce Product Display
import { formatRupiah, formatCompact } from '@indodev/toolkit/currency'
function ProductCard({ product }) {
const hasDiscount = product.discount > 0
const finalPrice = product.price * (1 - product.discount)
return (
<div className="product-card">
<h3>{product.name}</h3>
{hasDiscount ? (
<div className="price">
<span className="original">{formatRupiah(product.price)}</span>
<span className="final">{formatRupiah(finalPrice)}</span>
<span className="discount">{product.discount * 100}% OFF</span>
</div>
) : (
<div className="price">{formatRupiah(product.price)}</div>
)}
{product.installment && (
<div className="installment">
or {formatCompact(product.price / 12)}/month
</div>
)}
</div>
)
}Invoice Generator
import { formatRupiah, toWords } from '@indodev/toolkit/currency'
interface InvoiceData {
items: Array<{ name: string; qty: number; price: number }>
tax: number
discount: number
}
function generateInvoice(data: InvoiceData) {
const subtotal = data.items.reduce((sum, item) => {
return sum + item.qty * item.price
}, 0)
const discountAmount = subtotal * data.discount
const afterDiscount = subtotal - discountAmount
const taxAmount = afterDiscount * data.tax
const total = afterDiscount + taxAmount
return {
items: data.items.map(item => ({
...item,
amount: formatRupiah(item.qty * item.price)
})),
subtotal: formatRupiah(subtotal),
discount: formatRupiah(discountAmount),
tax: formatRupiah(taxAmount),
total: formatRupiah(total),
totalWords: toWords(total, { uppercase: true })
}
}
// Usage in component
function Invoice({ data }: { data: InvoiceData }) {
const invoice = generateInvoice(data)
return (
<div className="invoice">
<table>
<thead>
<tr>
<th>Item</th>
<th>Qty</th>
<th>Price</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
{invoice.items.map((item, i) => (
<tr key={i}>
<td>{item.name}</td>
<td>{item.qty}</td>
<td>{formatRupiah(item.price)}</td>
<td>{item.amount}</td>
</tr>
))}
</tbody>
</table>
<div className="summary">
<div>Subtotal: {invoice.subtotal}</div>
<div>Discount: -{invoice.discount}</div>
<div>PPN {data.tax * 100}%: {invoice.tax}</div>
<div className="total">Total: {invoice.total}</div>
<div className="terbilang">Terbilang: {invoice.totalWords}</div>
</div>
</div>
)
}Financial Dashboard
import { formatCompact, roundToClean } from '@indodev/toolkit/currency'
interface DashboardStats {
revenue: number
expenses: number
profit: number
growth: number
}
function FinancialDashboard({ stats }: { stats: DashboardStats }) {
return (
<div className="dashboard">
<div className="stat-grid">
<StatCard
label="Revenue"
value={formatCompact(stats.revenue)}
trend={stats.growth > 0 ? 'up' : 'down'}
change={`${(stats.growth * 100).toFixed(1)}%`}
/>
<StatCard
label="Expenses"
value={formatCompact(stats.expenses)}
/>
<StatCard
label="Profit"
value={formatCompact(stats.profit)}
highlight={stats.profit > 0}
/>
<StatCard
label="Target"
value={formatCompact(roundToClean(stats.revenue * 1.2, 'juta'))}
subtitle="Next quarter goal"
/>
</div>
</div>
)
}Payment Form with Validation
import { formatRupiah, parseRupiah } from '@indodev/toolkit/currency'
import { useState } from 'react'
function PaymentForm() {
const [displayAmount, setDisplayAmount] = useState('')
const [actualAmount, setActualAmount] = useState(0)
const [error, setError] = useState('')
const handleAmountChange = (input: string) => {
setDisplayAmount(input)
const parsed = parseRupiah(input)
if (parsed === null) {
setError('Invalid amount format')
setActualAmount(0)
} else if (parsed < 10000) {
setError('Minimum amount is Rp 10.000')
setActualAmount(parsed)
} else {
setError('')
setActualAmount(parsed)
}
}
const handleBlur = () => {
if (actualAmount > 0) {
setDisplayAmount(formatRupiah(actualAmount))
}
}
const handleSubmit = () => {
if (actualAmount < 10000) {
alert('Amount too low')
return
}
// Process payment
console.log('Processing payment:', actualAmount)
}
return (
<form>
<div className="form-group">
<label>Amount</label>
<input
type="text"
value={displayAmount}
onChange={e => handleAmountChange(e.target.value)}
onBlur={handleBlur}
placeholder="Rp 0"
/>
{error && <span className="error">{error}</span>}
</div>
<button
type="button"
onClick={handleSubmit}
disabled={actualAmount < 10000}
>
Pay {formatRupiah(actualAmount)}
</button>
</form>
)
}Budget Calculator
import { formatRupiah, roundToClean } from '@indodev/toolkit/currency'
interface BudgetCategory {
name: string
percentage: number
}
function BudgetCalculator({ income }: { income: number }) {
const categories: BudgetCategory[] = [
{ name: 'Kebutuhan Pokok', percentage: 50 },
{ name: 'Tabungan & Investasi', percentage: 20 },
{ name: 'Gaya Hidup', percentage: 30 },
]
const allocations = categories.map(cat => ({
...cat,
amount: income * (cat.percentage / 100),
rounded: roundToClean(income * (cat.percentage / 100), 'ribu')
}))
return (
<div className="budget-calculator">
<div className="income">
<h3>Penghasilan Bulanan</h3>
<div className="amount">{formatRupiah(income)}</div>
</div>
<div className="allocations">
{allocations.map(allocation => (
<div key={allocation.name} className="allocation">
<div className="category">
<span>{allocation.name}</span>
<span>{allocation.percentage}%</span>
</div>
<div className="amount">
{formatRupiah(allocation.rounded)}
</div>
</div>
))}
</div>
</div>
)
}Best Practices
1. Choose the Right Format
// ❌ BAD: Using full format in constrained spaces
<div className="small-card">
{formatRupiah(1500000)} // Too long
</div>
// ✅ GOOD: Use compact format for small spaces
<div className="small-card">
{formatCompact(1500000)} // Rp 1,5 juta
</div>
// ❌ BAD: Compact format for exact amounts
<div className="invoice-total">
{formatCompact(1234567)} // Loses precision
</div>
// ✅ GOOD: Full format for exact amounts
<div className="invoice-total">
{formatRupiah(1234567)} // Rp 1.234.567
</div>2. Handle Decimals Correctly
// ❌ BAD: Not showing decimals for prices with cents
formatRupiah(1500000.5);
// 'Rp 1.500.000' (loses .50)
// ✅ GOOD: Enable decimals when needed
formatRupiah(1500000.5, { decimal: true });
// 'Rp 1.500.000,50'
// ✅ GOOD: Set precision explicitly
formatRupiah(1500000.5, { decimal: true, precision: 2 });
// 'Rp 1.500.000,50'3. Validate User Input
// ❌ BAD: Directly using user input
function processPayment(input: string) {
const amount = parseFloat(input); // Can fail
// ...
}
// ✅ GOOD: Parse and validate
function processPayment(input: string) {
const amount = parseRupiah(input);
if (amount === null) {
throw new Error('Invalid amount format');
}
if (amount < 1000) {
throw new Error('Amount too low');
}
// Process valid amount
return amount;
}4. Use Terbilang for Documents
// ✅ GOOD: Always include terbilang in invoices
function InvoiceTotal({ amount }) {
return (
<div>
<div>Total: {formatRupiah(amount, { decimal: true })}</div>
<div>Terbilang: {toWords(amount, { uppercase: true })}</div>
</div>
)
}
// ✅ GOOD: Format checks properly
function Check({ amount, payee }) {
return (
<div className="check">
<div>Pay to the order of: {payee}</div>
<div>Sum of: {formatRupiah(amount)}</div>
<div className="words">({toWords(amount, { uppercase: true })})</div>
</div>
)
}5. Performance Optimization
// ❌ BAD: Formatting in loops without memoization
function ProductList({ products }) {
return products.map(product => (
<div key={product.id}>
{formatRupiah(product.price)} // Formatted on every render
</div>
))
}
// ✅ GOOD: Pre-format or memoize
function ProductList({ products }) {
const formatted = useMemo(
() => products.map(p => ({
...p,
formattedPrice: formatRupiah(p.price)
})),
[products]
)
return formatted.map(product => (
<div key={product.id}>
{product.formattedPrice}
</div>
))
}
// ✅ GOOD: Format once in data transformation
const products = rawProducts.map(p => ({
...p,
formattedPrice: formatRupiah(p.price)
}))6. Consistent Separator Usage
// ❌ BAD: Mixing separators in same application
formatRupiah(1000, { separator: '.' });
formatRupiah(2000, { separator: ',' }); // Inconsistent
// ✅ GOOD: Create wrapper with default options
const formatIDR = (amount: number, options?: RupiahOptions) => {
return formatRupiah(amount, {
separator: '.',
decimalSeparator: ',',
...options,
});
};
// Use everywhere
formatIDR(1000);
formatIDR(2000);Troubleshooting
Q: Why does parseRupiah return null?
A: Common reasons:
- Input contains non-numeric characters (except separators)
- Empty or invalid string
- Malformed format
// Check input first
const input = 'Rp abc';
const result = parseRupiah(input);
if (result === null) {
console.log('Invalid format'); // Will log this
}Q: How to handle decimal precision?
A: Set precision explicitly:
// Default: 2 decimals when decimal=true
formatRupiah(1500.5, { decimal: true });
// 'Rp 1.500,50'
// Custom precision
formatRupiah(1500.5, { decimal: true, precision: 1 });
// 'Rp 1.500,5'
formatRupiah(1500, { decimal: true, precision: 0 });
// 'Rp 1.500'Q: Can I format for international audience?
A: Yes, customize separators:
formatRupiah(1500000.5, {
separator: ',',
decimalSeparator: '.',
decimal: true,
});
// 'Rp 1,500,000.50'Q: Why is terbilang not showing decimals?
A: toWords() only converts integers. Format decimals separately:
const amount = 1500000.5;
const integer = Math.floor(amount);
const decimal = Math.round((amount - integer) * 100);
const words = `${toWords(integer)} koma ${toWords(decimal, { withCurrency: false })}`;
// 'satu juta lima ratus ribu rupiah koma lima puluh'Q: How to handle very large numbers?
A: The library supports up to trillions:
formatRupiah(1234567890123);
// 'Rp 1.234.567.890.123'
formatCompact(1234567890123);
// 'Rp 1,2 triliun'
toWords(1000000000000);
// 'satu triliun rupiah'Data Standards
All formatting follows official Indonesian standards:
- Separator:
.(dot) for thousands - Decimal:
,(comma) for decimals - Symbol:
Rpwith space - Units: ribu, juta, miliar, triliun
- Grammar: Proper Indonesian number pronunciation rules