From 0deaff6e185e92948451b5e8f3a1f5fd91a2dabf Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:52:20 +0700 Subject: [PATCH] feat: implement CurrencyService for formatting and parsing currency values, update InputCurrency component, and add tests --- apps/web/src/main.tsx | 3 + .../forms/input/input-currency.component.tsx | 65 +++++--- .../ui/src/components/forms/types/index.ts | 2 + .../currency-service/currency-service.test.ts | 99 ++++++++++++ .../src/currency-service/currency-service.ts | 152 ++++++++++++++++++ packages/utils/src/index.ts | 1 + 6 files changed, 297 insertions(+), 25 deletions(-) create mode 100644 packages/utils/src/currency-service/currency-service.test.ts create mode 100644 packages/utils/src/currency-service/currency-service.ts diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index f58fb30..505e979 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,4 +1,5 @@ // import { DateService } from '@repo/utils'; +// import { CurrencyService } from '@repo/utils'; import './main.css'; import { lazy, StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; @@ -6,6 +7,8 @@ const App = lazy(() => import('./apps')); // DateService.setGlobalConfig('Asia/Jakarta'); // DateService.setGlobalConfig('Asia/Makassar'); + +// CurrencyService.setGlobalPrefix('IDR '); createRoot(document.getElementById('app')!).render( diff --git a/packages/ui/src/components/forms/input/input-currency.component.tsx b/packages/ui/src/components/forms/input/input-currency.component.tsx index fb2bb82..d918429 100644 --- a/packages/ui/src/components/forms/input/input-currency.component.tsx +++ b/packages/ui/src/components/forms/input/input-currency.component.tsx @@ -1,40 +1,55 @@ +import { CurrencyService } from '@repo/utils'; import { InputCurrencyProps } from '../types'; import { InputNumber } from './input-number.component'; +/** + * InputCurrency + * + * A controlled InputNumber component with currency formatting. + * Delegates all formatting/parsing to CurrencyService. + * + * Features: + * - Display currency prefix (e.g., Rp, $) + * - Thousand separator formatting + * - Optional decimal rounding + * - Global prefix support via CurrencyService + */ export const InputCurrency = (props: InputCurrencyProps) => { - const { prefix = 'Rp ', ...restProps } = props; + const { + prefix, // Optional override for instance prefix + decimalSeparator, // Optional override for separator + decimalScale, // Optional rounding + ...restProps + } = props; - // Change format from 10000 to "Rp 10.000" - const formatCurrency = (value: number | string | undefined) => { - if (value === undefined || value === null || value === '') { - return ''; - } - // Convert value to string for processing - const valStr = `${value}`; - const formatted = valStr.replace(/\B(?=(\d{3})+(?!\d))/g, '.'); - return `${prefix}${formatted}`; + // Create a CurrencyService instance for this input + const currencyService = new CurrencyService({ + prefix, + decimalSeparator, + decimalScale, + }); + + /** + * Formatter: converts numeric value to formatted string for display + */ + const formatter = (value: number | string | undefined) => { + return currencyService.format(value); }; - // Change parse from "Rp 10.000" to 10000 - const parseCurrency = (displayValue: string | undefined) => { - if (!displayValue) return ''; - - // Remove prefix and all non-digit characters (except comma if decimal support is needed) - // Here we assume pure integer input, so dots (.) are removed. - const cleanValue = displayValue.replace(prefix, '').replace(/\./g, ''); - - // If you need decimal support (comma), use this regex instead: - // return displayValue.replace(prefix, '').replace(/\./g, '').replace(/,/g, '.'); - - return cleanValue; + /** + * Parser: converts formatted string back to raw numeric string + * Note: never rounds or modifies the underlying value + */ + const parser = (displayValue: string | undefined) => { + return currencyService.parse(displayValue); }; return ( ); diff --git a/packages/ui/src/components/forms/types/index.ts b/packages/ui/src/components/forms/types/index.ts index 6c11d0d..ecd8cce 100644 --- a/packages/ui/src/components/forms/types/index.ts +++ b/packages/ui/src/components/forms/types/index.ts @@ -53,6 +53,8 @@ export interface InputPasswordProps extends InputProps { export interface InputNumberProps extends RcInputNumberProps, BaseComponentProps {} export interface InputCurrencyProps extends InputNumberProps, BaseComponentProps { prefix?: string; // Optional: Custom prefix, default 'Rp ' + decimalSeparator?: ',' | '.'; + decimalScale?: number; // optional } /** diff --git a/packages/utils/src/currency-service/currency-service.test.ts b/packages/utils/src/currency-service/currency-service.test.ts new file mode 100644 index 0000000..6b26cf8 --- /dev/null +++ b/packages/utils/src/currency-service/currency-service.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { CurrencyService } from './currency-service'; + +describe('CurrencyService', () => { + let currency: CurrencyService; + + beforeEach(() => { + // Reset global prefix before each test + CurrencyService.setGlobalPrefix('Rp '); + + // Mock console to keep tests clean + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Default instance for testing + currency = new CurrencyService(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Global Prefix', () => { + it('should set and get global prefix', () => { + CurrencyService.setGlobalPrefix('USD '); + expect(CurrencyService.getGlobalPrefix()).toBe('USD '); + }); + + it('should use global prefix if instance prefix is not provided', () => { + CurrencyService.setGlobalPrefix('USD '); + const c = new CurrencyService(); + expect(c.format(1000)).toBe('USD 1.000'); + }); + + it('should override instance prefix', () => { + const c = new CurrencyService({ prefix: '€ ' }); + expect(c.format(1000)).toBe('€ 1.000'); + }); + }); + + describe('Formatting', () => { + it('should format integer without decimalScale', () => { + expect(currency.format(1234567)).toBe('Rp 1.234.567'); + }); + + it('should format number with decimal without decimalScale', () => { + expect(currency.format(1234.5678)).toBe('Rp 1.234,5678'); + }); + + it('should round decimal when decimalScale is set', () => { + const c = new CurrencyService({ decimalScale: 2 }); + expect(c.format(1234.5678)).toBe('Rp 1.234,56'); + }); + + it('should handle zero and negative values', () => { + expect(currency.format(0)).toBe('Rp 0'); + expect(currency.format(-12345)).toBe('Rp -12.345'); + }); + + it('should handle string input', () => { + expect(currency.format('1234567')).toBe('Rp 1.234.567'); + expect(currency.format('1234.5678')).toBe('Rp 1.234,5678'); + }); + + it('should return empty string for null or undefined', () => { + expect(currency.format(null)).toBe(''); + expect(currency.format(undefined)).toBe(''); + }); + }); + + describe('Parsing', () => { + it('should parse formatted string to raw numeric string', () => { + expect(currency.parse('Rp 1.234.567')).toBe('1234567'); + expect(currency.parse('Rp 1.234,5678')).toBe('1234.5678'); + }); + + it('should handle different decimal separators', () => { + const c = new CurrencyService({ decimalSeparator: '.' }); + expect(c.format(1234.56)).toBe('Rp 1,234.56'); + expect(c.parse('Rp 1,234.56')).toBe('1234.56'); + }); + + it('should return empty string for null or undefined input', () => { + expect(currency.parse(null)).toBe(''); + expect(currency.parse(undefined)).toBe(''); + }); + }); + + describe('Edge Cases', () => { + it('should format very large numbers correctly', () => { + expect(currency.format(1234567890123)).toBe('Rp 1.234.567.890.123'); + }); + + it('should format numbers with multiple decimals correctly when decimalScale is set', () => { + const c = new CurrencyService({ decimalScale: 3 }); + expect(c.format(1234.56789)).toBe('Rp 1.234,567'); + }); + }); +}); diff --git a/packages/utils/src/currency-service/currency-service.ts b/packages/utils/src/currency-service/currency-service.ts new file mode 100644 index 0000000..2991b00 --- /dev/null +++ b/packages/utils/src/currency-service/currency-service.ts @@ -0,0 +1,152 @@ +/** + * ------------------------------------------------------------ + * CurrencyService + * ------------------------------------------------------------ + * Centralized currency utility for formatting and parsing numeric values. + * + * Features: + * - Global prefix configuration (e.g., "Rp ") + * - Thousand separator formatting + * - Custom decimal separator support (e.g., "," for Indonesia) + * - Optional decimal rounding for display + * - Parsing of formatted strings back to raw numeric values + * + * Design Principles: + * - Formatter is for DISPLAY ONLY, it never mutates the original numeric value + * - Parser returns RAW value, never rounded + * - Supports instance-based overrides for decimal separator and rounding + * - Global prefix is available for consistency across the application + * ------------------------------------------------------------ + */ + +export type CurrencyInput = number | string | null | undefined; + +export interface CurrencyOptions { + /** Currency symbol or prefix for display, e.g., "Rp ", "$" */ + prefix?: string; + + /** Decimal separator for display, either "," or "." */ + decimalSeparator?: ',' | '.'; + + /** Optional number of decimal places to round for display */ + decimalScale?: number; +} + +export class CurrencyService { + /** ---------------------------------------------------------------- + * Global default prefix shared across all instances + * ---------------------------------------------------------------- */ + private static _globalPrefix: string = 'Rp '; + + /** Instance prefix, defaults to global prefix */ + private readonly prefix: string; + + /** Character used as decimal separator for display */ + private readonly decimalSeparator: string; + + /** Optional number of decimal digits for display rounding */ + private readonly decimalScale?: number; + + /** Thousand separator is inferred from decimal separator */ + private readonly thousandSeparator: string; + + /** + * Constructor for CurrencyService instance. + * Allows overriding prefix, decimal separator, and rounding per instance. + * + * @param options CurrencyOptions for prefix, separator, and rounding + */ + constructor(options?: CurrencyOptions) { + this.prefix = options?.prefix ?? CurrencyService._globalPrefix; + this.decimalSeparator = options?.decimalSeparator ?? ','; + this.decimalScale = options?.decimalScale; + this.thousandSeparator = this.decimalSeparator === ',' ? '.' : ','; + } + + /** ------------------------------------------------------------ + * Global prefix management + * ------------------------------------------------------------ */ + + /** + * Set a global currency prefix that will be used as default + * for all instances that do not provide a custom prefix. + * + * @param prefix New global prefix string + */ + static setGlobalPrefix(prefix: string) { + CurrencyService._globalPrefix = prefix; + } + + /** + * Get the current global prefix. + * + * @returns Current global prefix string + */ + static getGlobalPrefix(): string { + return CurrencyService._globalPrefix; + } + + /** ------------------------------------------------------------ + * Formatter (DISPLAY ONLY) + * ------------------------------------------------------------ */ + + /** + * Format a numeric value into a human-readable currency string. + * - Adds thousand separators + * - Uses the configured decimal separator + * - Optionally rounds decimal digits according to decimalScale + * + * Note: This method only affects display; it does NOT modify + * the underlying numeric value. + * + * @param value Numeric or string input to format + * @returns Formatted currency string with prefix + */ + format(value: CurrencyInput): string { + if (value === undefined || value === null || value === '') return ''; + + const stringValue = `${value}`; + const [integer, decimal] = stringValue.split('.'); + + // Add thousand separator to integer part + const formattedInteger = integer.replace(/\B(?=(\d{3})+(?!\d))/g, this.thousandSeparator); + + // If no decimalScale or no decimal part, display full decimal + if (this.decimalScale === undefined || !decimal) { + return `${this.prefix}${formattedInteger}${decimal ? this.decimalSeparator + decimal : ''}`; + } + + // Display rounded decimal according to decimalScale + const roundedDecimal = decimal.slice(0, this.decimalScale); + return `${this.prefix}${formattedInteger}${this.decimalSeparator}${roundedDecimal}`; + } + + /** ------------------------------------------------------------ + * Parser (RAW VALUE, NEVER ROUNDED) + * ------------------------------------------------------------ */ + + /** + * Parse a formatted currency string back into a raw numeric string. + * - Removes prefix and thousand separators + * - Converts decimal separator to dot (.) for numeric parsing + * + * This method is intended for backend submission or calculations. + * + * @param displayValue Formatted currency string + * @returns Raw numeric string + */ + parse(displayValue: string | undefined | null): string { + if (!displayValue) return ''; + + // Remove currency prefix + let value = displayValue.replace(this.prefix, ''); + + // Remove thousand separators + value = value.replace(new RegExp(`\\${this.thousandSeparator}`, 'g'), ''); + + // Normalize decimal separator to dot + if (this.decimalSeparator === ',') value = value.replace(',', '.'); + + return value; + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 73ff3f0..e3f7b33 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,3 +2,4 @@ export * from './encryption/encryption-key'; export * from './encryption/encryption-service'; export * from './date-service/date-service'; +export * from './currency-service/currency-service';