feat: implement CurrencyService for formatting and parsing currency values, update InputCurrency component, and add tests
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
// import { DateService } from '@repo/utils';
|
// import { DateService } from '@repo/utils';
|
||||||
|
// import { CurrencyService } from '@repo/utils';
|
||||||
import './main.css';
|
import './main.css';
|
||||||
import { lazy, StrictMode } from 'react';
|
import { lazy, StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
@@ -6,6 +7,8 @@ const App = lazy(() => import('./apps'));
|
|||||||
|
|
||||||
// DateService.setGlobalConfig('Asia/Jakarta');
|
// DateService.setGlobalConfig('Asia/Jakarta');
|
||||||
// DateService.setGlobalConfig('Asia/Makassar');
|
// DateService.setGlobalConfig('Asia/Makassar');
|
||||||
|
|
||||||
|
// CurrencyService.setGlobalPrefix('IDR ');
|
||||||
createRoot(document.getElementById('app')!).render(
|
createRoot(document.getElementById('app')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -1,40 +1,55 @@
|
|||||||
|
import { CurrencyService } from '@repo/utils';
|
||||||
import { InputCurrencyProps } from '../types';
|
import { InputCurrencyProps } from '../types';
|
||||||
import { InputNumber } from './input-number.component';
|
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) => {
|
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"
|
// Create a CurrencyService instance for this input
|
||||||
const formatCurrency = (value: number | string | undefined) => {
|
const currencyService = new CurrencyService({
|
||||||
if (value === undefined || value === null || value === '') {
|
prefix,
|
||||||
return '';
|
decimalSeparator,
|
||||||
}
|
decimalScale,
|
||||||
// Convert value to string for processing
|
});
|
||||||
const valStr = `${value}`;
|
|
||||||
const formatted = valStr.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
/**
|
||||||
return `${prefix}${formatted}`;
|
* 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) => {
|
* Parser: converts formatted string back to raw numeric string
|
||||||
if (!displayValue) return '';
|
* Note: never rounds or modifies the underlying value
|
||||||
|
*/
|
||||||
// Remove prefix and all non-digit characters (except comma if decimal support is needed)
|
const parser = (displayValue: string | undefined) => {
|
||||||
// Here we assume pure integer input, so dots (.) are removed.
|
return currencyService.parse(displayValue);
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputNumber
|
<InputNumber
|
||||||
{...restProps}
|
{...restProps}
|
||||||
formatter={formatCurrency}
|
formatter={formatter}
|
||||||
parser={parseCurrency}
|
parser={parser}
|
||||||
// Ensure minimum value is 0 for currency input
|
// Default min value to 0 for currency
|
||||||
min={props.min !== undefined ? props.min : 0}
|
min={props.min !== undefined ? props.min : 0}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export interface InputPasswordProps extends InputProps {
|
|||||||
export interface InputNumberProps extends RcInputNumberProps, BaseComponentProps {}
|
export interface InputNumberProps extends RcInputNumberProps, BaseComponentProps {}
|
||||||
export interface InputCurrencyProps extends InputNumberProps, BaseComponentProps {
|
export interface InputCurrencyProps extends InputNumberProps, BaseComponentProps {
|
||||||
prefix?: string; // Optional: Custom prefix, default 'Rp '
|
prefix?: string; // Optional: Custom prefix, default 'Rp '
|
||||||
|
decimalSeparator?: ',' | '.';
|
||||||
|
decimalScale?: number; // optional
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export * from './encryption/encryption-key';
|
|||||||
export * from './encryption/encryption-service';
|
export * from './encryption/encryption-service';
|
||||||
|
|
||||||
export * from './date-service/date-service';
|
export * from './date-service/date-service';
|
||||||
|
export * from './currency-service/currency-service';
|
||||||
|
|||||||
Reference in New Issue
Block a user