From 005509423b0bd096f1530d55f7b2a8531796c7a6 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sat, 8 Aug 2026 11:16:25 +0900 Subject: [PATCH] fix: read parenthesised accounting negatives on paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (1,234) — what Excel's accounting format and most ERP exports produce — parsed to 1234, silently flipping the sign on pasted money. For a library whose whole pitch is money-safe values in legacy Korean business forms, that is a correctness bug, not a formatting nicety. parse now normalizes the input once (full-width parens included) and reads the parenthesised form as negative when the negative option is on. The parens must actually enclose digits, so '(주)한국 1234' is untouched; currency signs may sit outside them; a minus inside does not negate twice; positive-only fields ignore the form entirely. Parens stay insignificant for caret math, so cursor behaviour is unchanged. Verified in a real browser as well as jsdom (this repo has a history of DOM bugs that whole-value unit tests missed): pasting (1,234) into a bound negative field displays -1,234 and getValue returns -1234. --- CHANGELOG.md | 15 +++++++++++++++ README.ko.md | 4 ++++ README.md | 5 +++++ package.json | 2 +- src/core.test.ts | 25 +++++++++++++++++++++++++ src/core.ts | 29 ++++++++++++++++++++++++++--- 6 files changed, 76 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abd19ab..04ddbbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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}` diff --git a/README.ko.md b/README.ko.md index 287bcc9..1779dcc 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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` — 비균일 그룹 크기). diff --git a/README.md b/README.md index 3ffb607..fe1a85a 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/package.json b/package.json index 0227c15..ae8e8be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devslab/numkey", - "version": "0.4.1", + "version": "0.4.2", "publishConfig": { "access": "public" }, diff --git a/src/core.test.ts b/src/core.test.ts index 3f9b223..29d30ad 100644 --- a/src/core.test.ts +++ b/src/core.test.ts @@ -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', () => { diff --git a/src/core.ts b/src/core.ts index 8018358..ae3026c 100644 --- a/src/core.ts +++ b/src/core.ts @@ -129,6 +129,8 @@ function normalizeChar(ch: string): string { if (ch === '.') return '.' if (ch === ',') return ',' if (ch === '-' || ch === '−' || ch === '﹣') return '-' + if (ch === '(') return '(' + if (ch === ')') return ')' return ch } @@ -136,6 +138,21 @@ 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. * @@ -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