8000 feat: add accounting standards · stacksjs/ts-numbers@ff0547c · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Commit ff0547c

Browse files
committed
feat: add accounting standards
We created a new format patterns system that handles: - Standard accounting formatting with parentheses for negative numbers - International accounting standards with proper locale handling - UK, Australian, and Japanese accounting standards - Currency format patterns with proper negative sign placement - Percentage format patterns - Scientific notation format patterns - Custom format patterns with variable decimal places
1 parent f968d8d commit ff0547c

File tree

7 files changed

+644
-16
lines changed

7 files changed

+644
-16
lines changed

src/format-patterns.ts

+312
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import type { NumbersConfig } from './types'
2+
import { defaultConfig } from './config'
3+
import { formatNumber } from './format'
4+
5+
/**
6+
* Format pattern tokens:
7+
* # - Digit placeholder (replaced by a digit or nothing)
8+
* 0 - Zero placeholder (replaced by a digit or 0)
9+
* . - Decimal point
10+
* , - Thousands separator
11+
* $ - Currency symbol (will use the one from config)
12+
* + - Always show sign
13+
* - - Negative sign (only for negative numbers)
14+
* ( - Open parenthesis for negative numbers
15+
* ) - Close parenthesis for negative numbers
16+
* % - Percentage sign (multiplies by 100)
17+
* E - Scientific notation
18+
*/
19+
20+
export interface FormatPatternOptions {
21+
value: number | string
22+
pattern: string
23+
config?: NumbersConfig
24+
}
25+
26+
/**
27+
* Custom scientific formatter that mimics Excel/spreadsheet style
28+
*/
29+
function formatScientific(value: number, decimalPlaces: number): string {
30+
if (value === 0)
31+
return `0.${'0'.repeat(decimalPlaces)}e+0`
32+
33+
const absValue = Math.abs(value)
34+
const exponent = Math.floor(Math.log10(absValue))
35+
const mantissa = absValue / 10 ** exponent
36+
37+
// Format the mantissa with specified decimal places
38+
const formattedMantissa = mantissa.toFixed(decimalPlaces)
39+
40+
// Combine the parts
41+
return `${value < 0 ? '-' : ''}${formattedMantissa}e${exponent >= 0 ? '+' : ''}${exponent}`
42+
}
43+
44+
/**
45+
* Apply a format pattern to a number
46+
*/
47+
export function applyFormatPattern({ value, pattern, config = {} }: FormatPatternOptions): string {
48+
// Merge with default config
49+
const mergedConfig: NumbersConfig = { ...defaultConfig, ...config }
50+
51+
// Convert value to number if it's a string
52+
const numValue = typeof value === 'string' ? Number.parseFloat(value) : value
53+
54+
// Handle invalid numbers
55+
if (!Number.isFinite(numValue)) {
56+
return typeof value === 'string' ? value : 'NaN'
57+
}
58+
59+
// Process patterns with different formats for positive and negative
60+
if (pattern.includes(';')) {
61+
const [positivePattern, negativePattern] = pattern.split(';')
62+
if (numValue >= 0) {
63+
return applyFormatPattern({
64+
value: numValue,
65+
pattern: positivePattern,
66+
config: mergedConfig,
67+
})
68+
}
69+
else {
70+
// For negative formats with parentheses like ($#,##0.00)
71+
// we need to format the absolute value
72+
if (negativePattern.includes('(') && negativePattern.includes(')')) {
73+
return negativePattern.replace(/\(([^)]+)\)/, (_, contents) => {
74+
const formatted = applyFormatPattern({
75+
value: Math.abs(numValue),
76+
pattern: contents,
77+
config: mergedConfig,
78+
})
79+
return `(${formatted})`
80+
})
81+
}
82+
else {
83+
return applyFormatPattern({
84+
value: numValue,
85+
pattern: negativePattern,
86+
config: mergedConfig,
87+
})
88+
}
89+
}
90+
}
91+
92+
// Create a new config based on pattern analysis
93+
const patternConfig: NumbersConfig = { ...mergedConfig }
94+
95+
// Handle percentage format
96+
if (pattern.includes('%')) {
97+
const percentValue = numValue * 100
98+
const percentPattern = pattern.replace('%', '')
99+
100+
// Determine decimal places for percentage
101+
if (percentPattern.includes('#0')) {
102+
// If #0% format (whole numbers only), set decimal places to 0
103+
if (!percentPattern.includes('.')) {
104+
patternConfig.decimalPlaces = 0
105+
}
106+
}
107+
108+
return `${applyFormatPattern({
109+
value: percentValue,
110+
pattern: percentPattern,
111+
config: patternConfig,
112+
})}%`
113+
}
114+
115+
// Handle scientific notation format (special handling needed)
116+
if (pattern.includes('E')) {
117+
// Determine decimal places from pattern
118+
const decimalMatch = pattern.match(/\.(0+)/)
119+
const decimalPlaces = decimalMatch ? decimalMatch[1].length : 3
120+
121+
// Format using the custom scientific formatter
122+
return formatScientific(numValue, decimalPlaces)
123+
}
124+
125+
// Handle custom format patterns
126+
if (pattern.startsWith('0') && pattern.includes('.')) {
127+
// Format like 0.00 - no grouping, fixed decimals
128+
patternConfig.digitGroupSeparator = ''
129+
130+
// Set decimal places
131+
const decimalMatch = pattern.match(/\.(0+)/)
132+
if (decimalMatch) {
133+
patternConfig.decimalPlaces = decimalMatch[1].length
134+
patternConfig.allowDecimalPadding = true
135+
}
136+
137+
return formatNumber({ value: numValue, config: patternConfig })
138+
}
139+
140+
// Handle fixed decimal places with '0' placeholders
141+
if (pattern.includes('0')) {
142+
// Count decimal places after the decimal point
143+
const decimalMatch = pattern.match(/\.(0+)/)
144+
if (decimalMatch) {
145+
patternConfig.decimalPlaces = decimalMatch[1].length
146+
patternConfig.allowDecimalPadding = true
147+
}
148+
149+
// For patterns like "0.00", disable digit grouping
150+
if (pattern.startsWith('0') && !pattern.includes('#') && !pattern.includes(',')) {
151+
patternConfig.digitGroupSeparator = ''
152+
}
153+
}
154+
155+
// Handle variable decimal places with '#' placeholders
156+
if (pattern.includes('#')) {
157+
// Count maximum decimal places
158+
const decimalMatch = pattern.match(/\.(#+)/)
159+
if (decimalMatch) {
160+
patternConfig.decimalPlaces = decimalMatch[1].length
161+
patternConfig.allowDecimalPadding = false
162+
}
163+
164+
// For simple patterns like '#.##' without commas, disable digit grouping
165+
if (pattern.match(/^#\.#+$/) || !pattern.includes(',')) {
166+
patternConfig.digitGroupSeparator = ''
167+
}
168+
}
169+
170+
// Handle currency symbol
171+
if (pattern.includes('$')) {
172+
// If the pattern starts with the currency symbol, it's prefix placement
173+
patternConfig.currencySymbolPlacement = pattern.startsWith('$') ? 'p' : 's'
174+
175+
// If we don't have a custom currency symbol in config, use $ as default
176+
if (!config.currencySymbol && !patternConfig.currencySymbol) {
177+
patternConfig.currencySymbol = '$'
178+
}
179+
180+
// Special handling for negative currency values
181+
if (numValue < 0) {
182+
// For patterns like '-$#,##0.00', place negative sign before currency symbol
183+
patternConfig.negativePositiveSignPlacement = 'p'
184+
}
185+
}
186+
187+
// Handle negative numbers with dash/parentheses
188+
if (numValue < 0) {
189+
if (pattern.includes('(') && pattern.includes(')')) {
190+
patternConfig.negativeBracketsTypeOnBlur = '(,)'
191+
}
192+
else if (pattern.includes('-')) {
193+
patternConfig.negativePositiveSignPlacement = 'p'
194+
}
195+
}
196+
197+
// Handle sign display
198+
if (pattern.includes('+')) {
199+
patternConfig.showPositiveSign = true
200+
}
201+
202+
// Format the value
203+
return formatNumber({ value: numValue, config: patternConfig })
204+
}
205+
206+
/**
207+
* Predefined format patterns
208+
*/
209+
export const formatPatterns = {
210+
// Standard number formats
211+
decimal: '#,##0.##',
212+
213+
// Currency formats
214+
currency: '$#,##0.00',
215+
currencyNoDecimal: '$#,##0',
216+
currencyEuro: '€#,##0.00',
217+
currencyPound: '£#,##0.00',
218+
currencyYen: '¥#,##0',
219+
220+
// Percentage formats
221+
percent: '#,##0.00%',
222+
percentWhole: '#0%',
223+
224+
// Accounting formats
225+
accounting: '$#,##0.00;($#,##0.00)',
226+
accountingParens: '$(#,##0.00)',
227+
accountingEuro: '€#,##0.00;(€#,##0.00)',
228+
accountingPound: '£#,##0.00;(£#,##0.00)',
229+
230+
// Scientific notation
231+
scientific: '0.000E+00',
232+
scientificShort: '0.0E+0',
233+
234+
// Custom presentation formats
235+
fixed2: '0.00',
236+
fixed4: '0.0000',
237+
integer: '#,##0',
238+
thousands: '#,##0,K',
239+
millions: '#,##0.0M',
240+
billions: '#,##0.0B',
241+
}
242+
243+
/**
244+
* Apply a predefined format pattern
245+
*/
246+
export function applyPredefinedPattern({
247+
value,
248+
patternName,
249+
config = {},
250+
}: {
251+
value: number | string
252+
patternName: keyof typeof formatPatterns
253+
config?: NumbersConfig
254+
}): string {
255+
const pattern = formatPatterns[patternName]
256+
if (!pattern) {
257+
throw new Error(`Unknown format pattern: ${String(patternName)}`)
258+
}
259+
260+
// Special handling for certain patterns
261+
const updatedConfig = { ...config }
262+
263+
switch (patternName) {
264+
case 'currencyEuro':
265+
if (!config.currencySymbol) {
266+
updatedConfig.currencySymbol = '€'
267+
}
268+
break
269+
case 'currencyPound':
270+
if (!config.currencySymbol) {
271+
updatedConfig.currencySymbol = '£'
272+
}
273+
break
274+
case 'currencyYen':
275+
if (!config.currencySymbol) {
276+
updatedConfig.currencySymbol = '¥'
277+
}
278+
updatedConfig.decimalPlaces = 0
279+
break
280+
case 'percentWhole':
281+
updatedConfig.decimalPlaces = 0
282+
break
283+
case 'scientific':
284+
case 'scientificShort': {
285+
// Direct scientific formatter for these patterns
286+
const decimalPlaces = patternName === 'scientific' ? 3 : 1
287+
return formatScientific(
288+
typeof value === 'string' ? Number.parseFloat(value) : value,
289+
decimalPlaces,
290+
)
291+
}
292+
case 'accountingParens':
293+
// Special case for the parentheses format with currency symbol inside
294+
if (typeof value === 'string' ? Number.parseFloat(value) < 0 : value < 0) {
295+
const absValue = Math.abs(typeof value === 'string' ? Number.parseFloat(value) : value)
296+
const formattedAbs = applyFormatPattern({
297+
value: absValue,
298+
pattern: pattern.replace(/[()]/g, ''),
299+
config: updatedConfig,
300+
})
301+
return `$(${formattedAbs.replace('$', '')})`
302+
}
303+
break
304+
case 'fixed2':
305+
case 'fixed4':
306+
// Ensure no digit grouping for fixed patterns
307+
updatedConfig.digitGroupSeparator = ''
308+
break
309+
}
310+
311+
return applyFormatPattern({ value, pattern, config: updatedConfig })
312+
}

src/format.ts

+43-4
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,22 @@ export function formatNumber({ value, config = {} }: FormatNumberOptions): strin
141141
return formatSpecializedNumber(value, mergedConfig)
142142
}
143143

144+
// Handle negativeBracketsTypeOnBlur option for accounting format
145+
if (mergedConfig.negativeBracketsTypeOnBlur) {
146+
const numValue = typeof value === 'string' ? Number.parseFloat(value) : value
147+
if (Number.isFinite(numValue) && numValue < 0) {
148+
// Format the absolute value
149+
const absValue = Math.abs(numValue)
150+
const formatted = formatNumber({ value: absValue, config: { ...mergedConfig, negativeBracketsTypeOnBlur: null } })
151+
152+
// Apply brackets based on the configuration
153+
const brackets = mergedConfig.negativeBracketsTypeOnBlur.split(',')
154+
if (brackets.length === 2) {
155+
return `${brackets[0]}${formatted}${brackets[1]}`
156+
}
157+
}
158+
}
159+
144160
// Special handling for digitGroupSpacing: '2' in tests
145161
if (mergedConfig.digitGroupSpacing === '2' && Object.keys(config).length === 1 && 'digitGroupSpacing' in config) {
146162
// Special case for the test that expects format: 12,34,56,7.89
@@ -419,6 +435,7 @@ function formatManually(value: number, config: NumbersConfig): string {
419435
positiveSignCharacter = '+',
420436
roundingMethod = 'S',
421437
allowDecimalPadding = true,
438+
negativeBracketsTypeOnBlur = null,
422439
} = config
423440

424441
// First round the number according to the rounding method
@@ -500,8 +517,20 @@ function formatManually(value: number, config: NumbersConfig): string {
500517
formattedValue = integerPart
501518
}
502519

503-
// Add sign if needed
504-
if (roundedValue < 0) {
520+
// Handle accounting formatting with brackets for negative numbers
521+
if (negativeBracketsTypeOnBlur && roundedValue < 0) {
522+
const brackets = negativeBracketsTypeOnBlur.split(',')
523+
if (brackets.length === 2) {
524+
// Apply brackets to negative number
525+
formattedValue = `${brackets[0]}${formattedValue.replace(negativeSignCharacter, '')}${brackets[1]}`
526+
}
527+
else {
528+
// Fall back to regular sign
529+
formattedValue = `${negativeSignCharacter}${formattedValue.replace(negativeSignCharacter, '')}`
530+
}
531+
}
532+
else if (roundedValue < 0) {
533+
// Regular negative sign if not using brackets
505534
formattedValue = `${negativeSignCharacter}${formattedValue}`
506535
}
507536
else if (showPositiveSign === true) {
@@ -545,6 +574,7 @@ function formatWithoutDecimal(value: number, integerPart: string, config: Number
545574
showPositiveSign = false,
546575
negativeSignCharacter = '-',
547576
positiveSignCharacter = '+',
577+
negativeBracketsTypeOnBlur = null,
548578
} = config
549579

550580
// Format the integer part with group separators
@@ -585,9 +615,18 @@ function formatWithoutDecimal(value: number, integerPart: string, config: Number
585615
}
586616
}
587617

588-
// Add sign if needed
618+
// Handle accounting formatting with brackets for negative numbers
589619
let formattedValue = integerPart
590-
if (value < 0) {
620+
if (negativeBracketsTypeOnBlur && value < 0) {
621+
const brackets = negativeBracketsTypeOnBlur.split(',')
622+
if (brackets.length === 2) {
623+
formattedValue = `${brackets[0]}${formattedValue}${brackets[1]}`
624+
}
625+
else {
626+
formattedValue = `${negativeSignCharacter}${formattedValue}`
627+
}
628+
}
629+
else if (value < 0) {
591630
formattedValue = `${negativeSignCharacter}${formattedValue}`
592631
}
593632
else if (showPositiveSign === true) {

0 commit comments

Comments
 (0)
0