korean-bank-detect
v0.1.1
Published
Korean bank account number detection via 금결원 CMS 계좌번호체계
Maintainers
Readme
korean-bank-detect
Detect the Korean deposit institution behind a CMS account number — from the account number alone.
A zero-dependency TypeScript library that maps a Korean bank/securities account number to its candidate deposit institutions, sourced entirely from the 금융결제원 CMS 계좌번호체계 reference (금융결제원 = Korea Financial Telecommunications & Clearings Institute), rev. 2026.06.01.
한국 계좌번호를 입력하면 입금 기관(은행·증권사) 후보를 판별합니다. 금융결제원 CMS 계좌번호체계 문서를 단일 출처로 삼습니다.
import { detect } from 'korean-bank-detect';
detect('212-123456-789').candidates[0].institutionName;
// → '수협중앙회' (resolved by the PDF's branch rule)Why
- Data, not opinion. Pure JSON in, pure JSON out. No UI, no
score, noconfidence— just the institution and the document-sourced evidence for it. - Faithful to the source. Every value traces to the 금결원 PDF. It does not compute or verify check digits (those algorithms aren't public) — it only reports the static flag the PDF itself declares.
- Ranks the right bank first. Uses the PDF's 계정과목코드 (account subject codes) as evidence: for a modern 차세대 account, the front 3-digit code identifies the bank uniquely, so it surfaces as candidate #1. Show your users the top 1–3.
- Recall-first. The true institution is never dropped from the candidate set, even for partial input. Candidates only shrink as you type more digits.
- Real branch routing. Where the document routes one code to another by digit value (수협 007 ⇄ 030), that rule is executed, not just described.
- Zero runtime dependencies. Works in Node, browsers, and React Native.
Install
npm install korean-bank-detect
# pnpm add korean-bank-detect · yarn add korean-bank-detect · bun add korean-bank-detectUsage
Quick start — bundled registry
detect() returns a short, pickable candidate list (~8 options, ranked) —
never the whole 30–50 institution registry.
import { detect } from 'korean-bank-detect';
detect('611-234567-890').candidates[0]; // 하나은행 (unique front 과목코드 611)
detect('212-123456-789').candidates[0]; // 수협중앙회 (수협 branch rule fired)
detect('3333-05-1234567').candidates[0]; // 카카오뱅크 (실무 프리픽스 3333 — see below)
const [best, ...rest] = detect('611-234567-890').candidates;
best.institutionName; // '하나은행'The narrowing model is evidence-based — every candidate falls into one of three grades:
| Grade | Meaning | Treatment |
|---|---|---|
| matched | its 과목코드/프리픽스/분기규칙 fits these digits | always included, ranked first |
| unknown | no signal capability at this length (length-only institutions) | no information — fills the remaining limit budget by rank (only while NOT determined) |
| contradicted | it HAS codes at this exact length, none matched | demoted last — reachable via limit: Infinity / matchAccount, never silently unreachable (code sets can't be proven exhaustive) |
Every candidate exposes its grade, so your UI can render the tiers explicitly.
The display rules:
- Suggestions gate at 7 digits (
minDigits, default 7 — the shortest documented account length; below it nothing can be complete, so real apps like Toss show nothing either). - DETERMINED — complete input with an exact-length strong signal → matched
only. A fully-typed 카카오 account is
[카카오뱅크], not 8 rows. While typing (before the pattern length is reached) suggestions stay padded. - Only exact-length strong signals determine. A 14-digit pattern's prefix firing while you're 13 digits in boosts ranking but does not suppress alternatives — a complete 13-digit account and a 14-digit account in progress are indistinguishable.
- Strong codes aren't always unique (
100(12d) → 우체국·K뱅크·토스). All matching institutions stay listed; priority orders them.
실무 프리픽스 — internet banks (progressive, like the real apps)
The PDF carries no 계정과목코드 for internet banks, so a practical prefix layer
(clearly separated from official data, evidence token 프리픽스) identifies them —
and it fires progressively while typing, exactly like the KakaoBank app:
detect('3333056').candidates[0].institutionName; // '카카오뱅크' — from the 7th digit
detect('3333', { minDigits: 4 }).candidates[0]; // 게이트를 낮추면 4자리부터도 가능
detect('3333051234567').candidates[0].matched; // ['기관코드','자릿수','프리픽스']
detect('100012345678').candidates[0].institutionName; // '토스뱅크'| Bank | Prefix | Source |
|---|---|---|
| 카카오뱅크 (13d) | 3333 입출금 · 7979/7942 모임통장 | KakaoBank official blog |
| 토스뱅크 (12d) | 1000 입출금 · 1060 모으기 · 3000 적금 | community-observed (나무위키) |
| 케이뱅크 (12d) | 100-XXX-XXXXXX | community-observed — genuinely shared with 토스's official 과목 100, so both stay listed |
Official 과목코드 front codes fire progressively too (typing 611 already floats
하나은행). These prefixes are evidence-only — they never remove other candidates.
detect() shows top 3 by default — the density real apps (Toss) use.
detect(account, { limit }) widens the list. For the full unfiltered
ranked list, call matchAccount(account, registry).
Bring your own registry — update DATA without a library release
Institution data changes on its own clock (금결원 PDF 개정, new prefixes, fixes). Host the registry JSON anywhere (DB, S3, config service) and pass it in — the bundled copy is just the fallback:
const remote = await fetch(REGISTRY_URL).then((r) => r.json()); // cache it
detect(acc, { registry: remote });Replace the stored JSON → every consumer picks it up. No npm release, no
redeploy. (registry.generatedFrom / sourceSha256 identify which PDF revision
a blob was built from.)
Hard filter — banks your service supports
The strongest narrowing lever is the one only you have: the list of banks your product actually handles.
detect(acc, { allowedCodes: ['004', '088', '090', '092'] }); // 국민·신한·카카오·토스만Category filter — 전체 / 은행 / 비은행 / 증권사 / 결제기관
Every candidate carries a category ('bank' | 'non-bank' | 'securities' |
'clearing' | 'foreign-branch'), and detect() can restrict to any subset:
detect(acc); // 전체 (default)
detect(acc, { categories: ['bank'] }); // 은행만
detect(acc, { categories: ['securities'] }); // 증권사만
detect(acc, { categories: ['bank', 'non-bank'] });// 은행 + 비은행'non-bank' covers 중앙회·금고·우체국·저축은행 (수협중앙회, 농협중앙회, 새마을금고중앙회,
신협중앙회, 상호저축은행, 산림조합중앙회, 우체국); 'clearing' is 금융결제원;
'foreign-branch' is the 5 foreign bank branches with no retail accounts for
individuals (HSBC·도이치·JP모간체이스·BOA·BNP파리바) — so categories: ['bank']
gives the 19 retail banks a transfer UI actually wants. (SC제일·한국씨티 are
foreign-owned but licensed Korean retail banks — they stay 'bank'.) A branch-routed
candidate carries the target's category — 수협중앙회 is non-bank, so it appears
under the non-bank filter, not the bank filter. The structural guarantee and
padding operate within the filtered set.
Two UX modes. The default detect() is a suggestion list (~limit items).
For a picker UI with 전체/은행/증권사 tabs — where 전체 must show everything (the
union of every tab) — pass limit: Infinity (or use matchAccount directly) and
filter by category; the ranking still puts the likeliest institution on top.
detect(acc, { limit: Infinity }); // 전체 — full ranked list
detect(acc, { limit: Infinity, categories: ['bank'] }); // 은행 탭Progressive input (as the user types)
detect('2').candidates.length; // many candidates
detect('21243').candidates.length; // fewer — narrowing
detect('21243345678').candidates[0].institutionName; // '수협중앙회'Code → institution (no account number needed)
The inverse direction of detect: you already hold a 기관코드 — a DB column, a
stored form value, a payment gateway echoing back the code you sent — and need
the 기관명. Resolves 대표코드 and 부코드 alike; no code is shared by two
institutions, so a hit is unambiguous.
import { findInstitutionByCode, getInstitutionName } from 'korean-bank-detect';
getInstitutionName('003'); // '기업은행'
getInstitutionName('013'); // '농협중앙회' (부코드)
getInstitutionName('999'); // undefined — caller owns the fallback
findInstitutionByCode('090')?.category; // 'bank'A stored value that may be a code or already a name:
const label = getInstitutionName(stored) ?? stored;Bring your own registry
Pin a specific revision instead of the bundled one:
import { matchAccount } from 'korean-bank-detect';
import registry from 'korean-bank-detect/registry' with { type: 'json' };
matchAccount('110-436-387740', registry);The lookups take a registry too — getInstitutionName(code, myRegistry).
Result shape
detect(account) and matchAccount(account, registry) both return:
interface AccountInstitutionMatchResult {
input: string; // the original string, verbatim
isComplete: boolean; // input length equals a candidate's documented full length
candidates: CandidateMatch[];
}Each candidate (real output for detect('212123456789')[0]):
{
"institutionCode": "030", // 대표코드 (the routed target, if a branch rule fired)
"institutionName": "수협중앙회",
"accountLengths": [11, 12, 14], // documented full lengths for this institution
"matched": ["기관코드", "자릿수", "분기규칙"], // evidence tokens satisfied
"checkDigitDeclared": false, // static flag from the PDF — NEVER computed
"routedByBranchRule": true, // a document-declared rule selected this institution
"logo": "" // reserved; empty for now (see Caveats)
}Candidates are deterministically ordered: by matched length (desc), then by
institutionName (Korean collation). A candidate whose subject code (과목코드) or
branch rule (분기규칙) matched carries an extra evidence token and therefore sorts
to the top — so the actual bank is candidate #0 for accounts that encode it.
detect('611234567890').candidates[0].institutionName; // '하나은행' (611 = 하나 차세대 보통예금)
detect('1011234567890').candidates[0].institutionName; // '부산은행' (101 = 부산 신계좌 보통예금)For a real "which bank?" UX, take candidates[0] (or show the top 2–3 and let the
user confirm — the true bank is always present).
Behavior
| Input | Result |
|---|---|
| Hyphens / whitespace | stripped before matching ("212-123456-789" ≡ "212123456789") |
| Empty / non-digit / over-length string | { candidates: [], isComplete: false } — never throws |
| Non-string (null, number, object, …) | same as above, never throws |
| Partial input | all reachable candidates; the set only shrinks as digits grow |
수협 branch routing
The PDF splits 수협 accounts between 007 수협은행 and 030 수협중앙회 by digit value. This library executes those rules verbatim:
| Length | Deciding digits | Routes to 030 수협중앙회 when the value is | |---|---|---| | 11 | 4th–5th | 43–45, 47, 49, 59, 61–64, 66–68, 74, 75, 78, 81–85, 93 | | 12 | 1st | 2, 7, 9 | | 14 | 1st–3rd | 493, 481–489 |
Before the deciding digits are present, the account stays a 수협은행 (007) candidate; once they arrive, it is definitively routed.
API
| Export | Signature | Notes |
|---|---|---|
| detect | (account: unknown) => AccountInstitutionMatchResult | uses the bundled registry |
| matchAccount | (account: unknown, registry: Registry) => AccountInstitutionMatchResult | pure; pass any registry |
| findInstitutionByCode | (code: unknown, registry?: Registry) => RegistryInstitution \| undefined | 기관코드 → 기관. 대표코드·부코드 모두 |
| getInstitutionName | (code: unknown, registry?: Registry) => string \| undefined | 기관명만 |
| registry | Registry | the bundled 금결원 CMS registry (26.06.01) |
Types exported: AccountInstitutionMatchResult, CandidateMatch, Registry,
RegistryInstitution, BranchRule.
Caveats
- A single guaranteed answer is mathematically impossible from the account
number alone. Korean account numbers don't uniquely encode the bank: a modern
차세대 account (front 3-digit 과목코드) usually pins one bank, but an old 구계좌
(shared 2-digit code like
01= 보통예금) is used by dozens of banks. Internet banks are covered by the 실무 프리픽스 layer above; old shared-code accounts are why Toss shows several suggestions and confirms with a live account-holder inquiry.detect()gives you the short candidate list; for a definitive pick, add a 예금주조회 (account-holder inquiry) API on top — out of scope here. The structural floor, by bank count per length: 10d → 11 · 11d → 14 · 12d → 20 · 13d → 13 · 14d → 16 banks. Without a subject-code/prefix signal, no offline method can narrow below this. - Subject-code coverage. All institutions that carry a 계정과목코드 in the PDF (~37) are encoded, cross-referenced between this repo's own PDF extraction and the MIT-licensed korean-account dataset (a faithful transcription of the same KFTC document; spot-checks match exactly). Institutions with no subject code in the PDF are covered by the 실무 프리픽스 layer where a stable real-world prefix exists (internet banks); a few pure-serial securities accounts remain structurally indistinguishable — an inherent limit of the document.
- Priority is a display heuristic, not data. There is no official
popularity/usage reference for Korean banks — the bundled
priorityvalues are an experience-based ordering inherited from the korean-account dataset (no sample, no formula, no observation window). It only breaks ties and picks whichunknowncandidates fill the display budget. For production ranking, prefer, in order:allowedCodes(banks your service supports) → structural evidence (grade/exactStrength) → your own per-user selection & verification stats → static priority last. - Never auto-trust
candidates[0]for money movement. UseexactStrength >= 3as the minimum bar for even suggesting auto-selection, let the user confirm, and verify with a 예금주조회 (account-holder inquiry) API before transferring. - Length data. Core institutions (수협, 상호저축은행, …) were verified against the
PDF; some institutions'
accountLengthsmay be slightly broad (more candidates, never fewer — recall stays safe). File an issue with a misdetection and that institution can be tightened. - Logos. The
logofield exists in the schema but is""— bank logos are trademarks with no official redistributable source; supply your own URLs. - Snapshot. Data is a snapshot of the 2026.06.01 revision. Tracking future revisions of the PDF is out of scope.
Development
node scripts/build-registry.mjs # regenerate data/registry.json (byte-identical)
npm test # data fidelity · branch routing · input contract
npm run build # emit dist/The registry is generated deterministically from a reviewed source
(scripts/registry-source.mjs) whose every fact is traceable to the 금결원 PDF.
Changelog
0.1.1
- Added
findInstitutionByCode(code, registry?)/getInstitutionName(code, registry?)— the code → institution direction. Until now only 계좌번호 → 기관 (detect) was exposed, so callers holding a bare 기관코드 (a DB column, a stored form value, a payment gateway echo) had to walkregistry.institutionsthemselves. Resolves 대표코드 and 부코드 alike; the per-registry index is built once and cached, so lookups are O(1). Non-string input returnsundefined— the caller owns the fallback. No behavior change todetect/matchAccount.
0.1.0
Initial release. 금결원 CMS 계좌번호체계 (26.06.01) as the single source of truth.
detect/matchAccount— account number → institution, with progressive (partial input) narrowing and a short pickable candidate list.- Evidence-based grading (
matched/unknown/contradicted) — a contradicted candidate is demoted, never silently dropped. - Filters:
allowedCodes,excludeCodes,categories(bank·non-bank·securities·clearing·foreign-branch),limit,minDigits. options.registry— swap the data without a library release.- 수협 007 ⇄ 030 branch routing; 실무 프리픽스 for internet banks.
- 56 institutions, 99 codes, zero dependencies.
