Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## 0.4.2 (2026-08-08)

### Fixed

- **Accounting negatives no longer lose their sign.** `(1,234)` — the
parenthesised negative Excel's accounting format and most ERP exports
produce — parsed to `1234`, silently flipping the sign on pasted money.
It now reads as `-1234` on a `negative` field, with the currency sign
allowed outside the parens (`₩(1,234)`, `(1,234)원`) and full-width
parens normalized. Parentheses that don't enclose digits (`(주)한국 1234`)
are ignored, a minus inside the parens doesn't negate twice, and
positive-only fields are unaffected. / **회계식 음수 부호 소실 수정** —
엑셀·ERP가 내보내는 `(1,234)`가 `1234`로 파싱되어 붙여넣은 금액의 부호가
조용히 뒤집히던 문제. 이제 `negative` 필드에서 `-1234`로 읽힙니다.

## 0.4.1 (2026-07-17)

Docs-only republish — no code changes. Runnable `examples/{vanilla,vue,react}`
Expand Down
4 changes: 4 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,10 @@ const [amount, setAmount] = useState('')

- 유럽식 포맷도 옵션으로 지원: `{ separator: '.', decimalPoint: ',' }`이면
`1.234.567,89`로 표시되고 정식 값은 `"1234567.89"`로 유지됩니다.
- 회계식 음수도 그대로 붙여넣어집니다: 엑셀·ERP가 내보내는 `(1,234)`가
`negative` 필드에서 `-1234`로 읽힙니다 (통화 기호는 괄호 밖에 있어도
됩니다). `(주)한국 1234`처럼 숫자를 감싸지 않은 괄호는 건드리지 않고,
양수 전용 필드는 이 표기를 무시합니다.
- 구분자 옆 백스페이스/Delete는 한 번에 인접한 숫자를 지웁니다 (구분자는
건너뛰고, 재포맷이 나머지를 처리).
- 로드맵: 인도식 lakh 그룹핑 (`12,34,567` — 비균일 그룹 크기).
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,11 @@ const [amount, setAmount] = useState('')

- European formats work via options: `{ separator: '.', decimalPoint: ',' }`
displays `1.234.567,89` while the canonical value stays `"1234567.89"`.
- Accounting negatives paste correctly: `(1,234)` — what Excel and most ERP
exports produce — reads as `-1234` on a `negative` field (currency signs
may sit outside the parens). Parentheses that don't enclose digits, like
`(주)한국 1234`, are left alone, and a positive-only field ignores the
form entirely.
- Backspace/Delete next to a group separator deletes the adjacent digit in
one keystroke (the separator is skipped, and the reformat handles the rest).
- Roadmap: Indian lakh grouping (`12,34,567` — non-uniform group sizes).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@devslab/numkey",
"version": "0.4.1",
"version": "0.4.2",
"publishConfig": {
"access": "public"
},
Expand Down
25 changes: 25 additions & 0 deletions src/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,31 @@ describe('parse — display/paste mess → canonical', () => {
'1234567.89'
)
})

it('reads parentheses as an accounting negative (Excel / ERP exports)', () => {
const o = { negative: true, decimals: 2 }
expect(parse('(1,234)', o)).toBe('-1234')
expect(parse('(1,234.56)', o)).toBe('-1234.56')
expect(parse('₩(1,234)', o)).toBe('-1234') // currency outside the parens
expect(parse('(1,234)원', o)).toBe('-1234')
expect(parse('( 1,234 )', o)).toBe('-1234')
expect(parse('(1,234)', o)).toBe('-1234') // full-width, Korean IME
})

it('does not negate twice when a minus sits inside the parens', () => {
expect(parse('(-1,234)', { negative: true })).toBe('-1234')
})

it('ignores parentheses that do not enclose digits', () => {
expect(parse('(주)한국 1234', { negative: true })).toBe('1234')
expect(parse('(abc)', { negative: true })).toBe('')
expect(parse('1234)', { negative: true })).toBe('1234') // unpaired
expect(parse('(1234', { negative: true })).toBe('1234') // unpaired
})

it('ignores the accounting form on a positive-only field', () => {
expect(parse('(1,234)')).toBe('1234')
})
})

describe('format — canonical → display', () => {
Expand Down
29 changes: 26 additions & 3 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,30 @@ function normalizeChar(ch: string): string {
if (ch === '.') return '.'
if (ch === ',') return ','
if (ch === '-' || ch === '−' || ch === '﹣') return '-'
if (ch === '(') return '('
if (ch === ')') return ')'
return ch
}

function isDigit(ch: string): boolean {
return ch >= '0' && ch <= '9'
}

/**
* Accounting notation writes negatives in parentheses — "(1,234)" is -1234 —
* which is what Excel's accounting format and most ERP exports produce. The
* parens must actually enclose digits, so a stray bracket in pasted text
* ("(주)한국 1234") does not flip the sign; currency signs may sit outside
* them ("₩(1,234)", "(1,234)원").
*/
function isAccountingNegative(normalized: string): boolean {
const open = normalized.indexOf('(')
if (open === -1) return false
const close = normalized.lastIndexOf(')')
if (close < open) return false
return /\d/.test(normalized.slice(open + 1, close))
}

/**
* Display (or any pasted mess) → canonical numeric string.
*
Expand All @@ -144,17 +161,23 @@ function isDigit(ch: string): boolean {
* currency signs — is dropped. Leading zeros collapse ("007" → "7") but a
* lone zero and "0.x" survive. The fraction is cut at `decimals` digits.
* A trailing decimal mark and a lone minus are preserved (transient states).
*
* Parenthesised input is read as an accounting negative when `negative` is
* on ("(1,234)" → "-1234"); a minus alongside the parens does not negate
* twice.
*/
export function parse(input: string, opts?: NumkeyOptions): string {
const o = resolveOptions(opts)
let text = ''
for (const raw of input) text += normalizeChar(raw)

let intPart = ''
let fracPart = ''
let seenPoint = false
let seenAny = false
let neg = false
let neg = o.negative && isAccountingNegative(text)

for (const raw of input) {
const ch = normalizeChar(raw)
for (const ch of text) {
if (isDigit(ch)) {
seenAny = true
if (seenPoint) fracPart += ch
Expand Down