korean-bank-detect 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +320 -0
- package/data/registry.json +2347 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +92 -0
- package/dist/match.d.ts +161 -0
- package/dist/match.js +172 -0
- package/package.json +57 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Registry, type AccountInstitutionMatchResult, type InstitutionCategory } from './match.js';
|
|
2
|
+
/** The bundled 금결원 CMS registry (26.06.01). */
|
|
3
|
+
export declare const registry: Registry;
|
|
4
|
+
export interface DetectOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Max candidates to display (default 3 — the density real apps like Toss
|
|
7
|
+
* use). Grade-ordered: matched → unknown → contradicted, each rank-ordered.
|
|
8
|
+
* Raise it (or use Infinity for the full picker list) to see more.
|
|
9
|
+
*/
|
|
10
|
+
limit?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Restrict candidates to these categories — e.g. ['bank'] 은행만,
|
|
13
|
+
* ['securities'] 증권사만, ['bank', 'non-bank'] 은행+비은행.
|
|
14
|
+
* Omit (or empty) for 전체. Applied BEFORE shortlisting, so the structural
|
|
15
|
+
* guarantee and padding operate within the filtered set.
|
|
16
|
+
*/
|
|
17
|
+
categories?: InstitutionCategory[];
|
|
18
|
+
/**
|
|
19
|
+
* HARD filter: only these 대표코드 institutions are eligible (e.g. the banks
|
|
20
|
+
* your service actually supports for transfers). This is the first, strongest
|
|
21
|
+
* narrowing lever — recommended over relying on the heuristic priority.
|
|
22
|
+
*/
|
|
23
|
+
allowedCodes?: string[];
|
|
24
|
+
/**
|
|
25
|
+
* HARD filter, inverse: drop these 대표코드 institutions. Useful for syncing
|
|
26
|
+
* with an existing app's bank list by subtraction (e.g. categories:['bank']
|
|
27
|
+
* plus a few exclusions) instead of maintaining a full whitelist.
|
|
28
|
+
* Applied after allowedCodes/categories.
|
|
29
|
+
*/
|
|
30
|
+
excludeCodes?: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Minimum digits before suggestions appear (default 7 — the shortest
|
|
33
|
+
* documented Korean account length; below it no account can even be complete,
|
|
34
|
+
* so suggestions would be pure speculation. Real apps like Toss behave the
|
|
35
|
+
* same). Below the threshold `candidates` is empty. Set 0 to disable.
|
|
36
|
+
*/
|
|
37
|
+
minDigits?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Use a different registry than the bundled one — load the SAME JSON shape
|
|
40
|
+
* from your DB / remote storage / config service and pass it here. This
|
|
41
|
+
* decouples DATA updates (금결원 PDF 개정, prefix 추가, 오류 수정) from library
|
|
42
|
+
* releases: replace the stored JSON, no npm update or redeploy needed.
|
|
43
|
+
* The bundled registry remains the fallback.
|
|
44
|
+
*/
|
|
45
|
+
registry?: Registry;
|
|
46
|
+
}
|
|
47
|
+
export declare function detect(account: unknown, options?: DetectOptions): AccountInstitutionMatchResult;
|
|
48
|
+
export type { BranchRule, RegistryInstitution, Registry, CandidateMatch, AccountInstitutionMatchResult, InstitutionCategory, } from './match.js';
|
|
49
|
+
export { matchAccount } from './match.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* korean-bank-detect
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency TypeScript library that identifies the Korean deposit
|
|
5
|
+
* institution(s) a CMS account number belongs to, using the 금융결제원 CMS
|
|
6
|
+
* 계좌번호체계 PDF (26.06.01) as the single source of truth.
|
|
7
|
+
*
|
|
8
|
+
* Pure data in, pure JSON out. No UI, no score/confidence, no check-digit
|
|
9
|
+
* computation. Matching is by documented account length plus machine-executable
|
|
10
|
+
* branch rules (수협 007 ⇄ 030). Partial input is supported (progressive
|
|
11
|
+
* narrowing); the true institution is never dropped from the candidate set.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* import { detect } from 'korean-bank-detect';
|
|
15
|
+
* const { candidates } = detect('212123456789');
|
|
16
|
+
* candidates[0].institutionName; // '수협중앙회' (routed by branch rule)
|
|
17
|
+
*
|
|
18
|
+
* @example // pass your own registry (e.g. a pinned/older revision)
|
|
19
|
+
* import { matchAccount } from 'korean-bank-detect';
|
|
20
|
+
* import registry from 'korean-bank-detect/registry' with { type: 'json' };
|
|
21
|
+
* matchAccount('110-436-387740', registry);
|
|
22
|
+
*/
|
|
23
|
+
import registryJson from '../data/registry.json' with { type: 'json' };
|
|
24
|
+
import { matchAccount, } from './match.js';
|
|
25
|
+
/** The bundled 금결원 CMS registry (26.06.01). */
|
|
26
|
+
export const registry = registryJson;
|
|
27
|
+
/**
|
|
28
|
+
* Match against the bundled registry and return a SHORT, pickable candidate list
|
|
29
|
+
* — the point is "show a few options to pick", not 30.
|
|
30
|
+
*
|
|
31
|
+
* Narrowing — evidence-based:
|
|
32
|
+
* 1. hard filters first: `allowedCodes` (your service's supported banks),
|
|
33
|
+
* `categories`, and documented-length compatibility (membership itself).
|
|
34
|
+
* 2. DETERMINED input — an exact-length strong signal exists (exactStrength
|
|
35
|
+
* ≥ 3: 실무 프리픽스 / front 과목코드 / 분기규칙 at exactly this length, which
|
|
36
|
+
* also implies the input is a complete documented length) → return the
|
|
37
|
+
* 'matched' candidates ONLY. A fully-typed 카카오 account is [카카오뱅크],
|
|
38
|
+
* not 8 rows. Unknown/contradicted stay reachable via `limit: Infinity`,
|
|
39
|
+
* the `grade` field on `matchAccount`, or your own full bank picker.
|
|
40
|
+
* 3. NOT determined (typing in progress, or only weak shared codes) → the
|
|
41
|
+
* matched candidates plus a display budget (`limit` − matched) filled by
|
|
42
|
+
* rank with 'unknown' first (no signal capability — no information),
|
|
43
|
+
* then 'contradicted' (codes at this length, none matched — demoted last,
|
|
44
|
+
* never silently unreachable).
|
|
45
|
+
*
|
|
46
|
+
* Ranking within the list is matchAccount's: strength (incl. progressive
|
|
47
|
+
* prefix boosts) → evidence count → priority → 가나다. Note `priority` is a
|
|
48
|
+
* HEURISTIC display order (no official popularity source exists) — for real
|
|
49
|
+
* products, re-rank ties with your own verification stats and confirm via a
|
|
50
|
+
* 예금주조회 API before money moves. Never auto-trust candidates[0]; treat
|
|
51
|
+
* `exactStrength >= 3` as the minimum bar for even suggesting auto-selection.
|
|
52
|
+
*
|
|
53
|
+
* `limit: Infinity` → the full ranked pool (picker UX; 전체/탭 화면용).
|
|
54
|
+
* For the raw unfiltered list use `matchAccount(account, registry)`.
|
|
55
|
+
*/
|
|
56
|
+
const STRONG = 3;
|
|
57
|
+
export function detect(account, options = {}) {
|
|
58
|
+
const limit = options.limit ?? 3;
|
|
59
|
+
const minDigits = options.minDigits ?? 7;
|
|
60
|
+
const full = matchAccount(account, options.registry ?? registry);
|
|
61
|
+
const digitCount = typeof account === 'string' ? account.replace(/[-\s]/g, '').length : 0;
|
|
62
|
+
if (digitCount < minDigits)
|
|
63
|
+
return { ...full, candidates: [] };
|
|
64
|
+
let pool = full.candidates;
|
|
65
|
+
if (options.allowedCodes && options.allowedCodes.length > 0) {
|
|
66
|
+
const allowed = new Set(options.allowedCodes);
|
|
67
|
+
pool = pool.filter((c) => allowed.has(c.institutionCode));
|
|
68
|
+
}
|
|
69
|
+
if (options.categories && options.categories.length > 0) {
|
|
70
|
+
pool = pool.filter((c) => options.categories.includes(c.category));
|
|
71
|
+
}
|
|
72
|
+
if (options.excludeCodes && options.excludeCodes.length > 0) {
|
|
73
|
+
const excluded = new Set(options.excludeCodes);
|
|
74
|
+
pool = pool.filter((c) => !excluded.has(c.institutionCode));
|
|
75
|
+
}
|
|
76
|
+
if (limit === Infinity)
|
|
77
|
+
return { ...full, candidates: pool };
|
|
78
|
+
const determined = pool.some((c) => c.exactStrength >= STRONG);
|
|
79
|
+
if (determined) {
|
|
80
|
+
// complete input with an exact strong signal → matched only (top `limit`)
|
|
81
|
+
return { ...full, candidates: pool.filter((c) => c.grade === 'matched').slice(0, limit) };
|
|
82
|
+
}
|
|
83
|
+
// grade-ordered display: matched → unknown → contradicted (rank order within
|
|
84
|
+
// each grade), capped at `limit` — the Toss-style top-N suggestion density
|
|
85
|
+
const ordered = [
|
|
86
|
+
...pool.filter((c) => c.grade === 'matched'),
|
|
87
|
+
...pool.filter((c) => c.grade === 'unknown'),
|
|
88
|
+
...pool.filter((c) => c.grade === 'contradicted'),
|
|
89
|
+
];
|
|
90
|
+
return { ...full, candidates: ordered.slice(0, limit) };
|
|
91
|
+
}
|
|
92
|
+
export { matchAccount } from './match.js';
|
package/dist/match.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matching engine for Korean CMS account numbers.
|
|
3
|
+
*
|
|
4
|
+
* Accepts a raw account-number string (may be partial, hyphenated, or malformed)
|
|
5
|
+
* and the institution registry, and returns candidate deposit institutions each
|
|
6
|
+
* with a document-sourced matching-evidence array.
|
|
7
|
+
*
|
|
8
|
+
* Guarantees (per seed contract):
|
|
9
|
+
* - Never throws on any input value.
|
|
10
|
+
* - Empty candidates + isComplete=false for malformed/empty/non-digit/oversized
|
|
11
|
+
* strings and non-string values.
|
|
12
|
+
* - Document-based recall: an institution stays a candidate at every prefix until
|
|
13
|
+
* its documented lengths become unreachable (candidate set only shrinks).
|
|
14
|
+
* - Machine-executable definitive routing for document-declared branch rules
|
|
15
|
+
* (수협 007 ⇄ 030): once the deciding digits are present, the candidate is
|
|
16
|
+
* routed to the target institution.
|
|
17
|
+
* - No score/confidence fields. Deterministic sort: matched-count desc → name 가나다순.
|
|
18
|
+
* - Only internal normalization: strip hyphens/whitespace before matching.
|
|
19
|
+
*/
|
|
20
|
+
/** A digit-value branch rule: routes one 대표코드 to another by examined digits. */
|
|
21
|
+
export interface BranchRule {
|
|
22
|
+
/** the length-pattern this rule applies to */
|
|
23
|
+
length: number;
|
|
24
|
+
/** 0-indexed start of the examined digit window */
|
|
25
|
+
position: number;
|
|
26
|
+
/** number of digits examined */
|
|
27
|
+
matchLength: number;
|
|
28
|
+
/** exact values and/or inclusive {from,to} ranges that trigger routing */
|
|
29
|
+
match: Array<number | {
|
|
30
|
+
from: number;
|
|
31
|
+
to: number;
|
|
32
|
+
}>;
|
|
33
|
+
/** institution code routed TO when the digits match */
|
|
34
|
+
targetCode: string;
|
|
35
|
+
/** institution name routed TO when the digits match */
|
|
36
|
+
targetName: string;
|
|
37
|
+
/** verbatim PDF sentence declaring the rule */
|
|
38
|
+
sourceQuote: string;
|
|
39
|
+
}
|
|
40
|
+
/** 계정과목코드 spec: subject code sits at [position, position+codeLength) for this length. */
|
|
41
|
+
export interface SubjectCodeSpec {
|
|
42
|
+
length: number;
|
|
43
|
+
position: number;
|
|
44
|
+
codeLength: number;
|
|
45
|
+
codes: string[];
|
|
46
|
+
/**
|
|
47
|
+
* true for PRACTICAL prefixes (실무 관측 데이터, e.g. 카카오 3333) — sourced
|
|
48
|
+
* outside the PDF and reported with a '프리픽스' evidence token instead of '과목코드'.
|
|
49
|
+
*/
|
|
50
|
+
practical?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* true when the PDF marks this pattern as a 가상계좌 (virtual/deposit-shell
|
|
53
|
+
* account, e.g. 아이엠 14d 937/938/999 모임통장용). A virtual-code match is a
|
|
54
|
+
* weaker prior for user-typed transfer targets, so its ranking strength is
|
|
55
|
+
* HALVED — the candidate stays matched and listed, just below real-account
|
|
56
|
+
* matches (국민 938... 실계좌 vs 아이엠 938 가상계좌 충돌의 원칙적 해소).
|
|
57
|
+
*/
|
|
58
|
+
virtual?: boolean;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Institution category:
|
|
62
|
+
* 'bank' 은행 · 'non-bank' 비은행(중앙회·금고·우체국·저축은행) ·
|
|
63
|
+
* 'securities' 증권사 · 'clearing' 결제기관(금융결제원) ·
|
|
64
|
+
* 'foreign-branch' 외은지점(HSBC·도이치·JP모간·BOA·BNP — 개인 리테일 없음)
|
|
65
|
+
*/
|
|
66
|
+
export type InstitutionCategory = 'bank' | 'non-bank' | 'securities' | 'clearing' | 'foreign-branch';
|
|
67
|
+
export interface RegistryInstitution {
|
|
68
|
+
representativeCode: string;
|
|
69
|
+
name: string;
|
|
70
|
+
category: InstitutionCategory;
|
|
71
|
+
allCodes: string[];
|
|
72
|
+
accountLengths: number[];
|
|
73
|
+
/** static PDF-transcribed flag; NEVER computed from an account number */
|
|
74
|
+
checkDigitDeclared: boolean | null;
|
|
75
|
+
branchRules: BranchRule[];
|
|
76
|
+
/** subject-code specs for evidence-based narrowing (may be absent for some institutions) */
|
|
77
|
+
subjectCodes?: SubjectCodeSpec[];
|
|
78
|
+
/** tie-break only: major banks outrank minor ones at equal match strength */
|
|
79
|
+
priority?: number;
|
|
80
|
+
logo: string;
|
|
81
|
+
}
|
|
82
|
+
export interface Registry {
|
|
83
|
+
generatedFrom: string;
|
|
84
|
+
sourceSha256: string | null;
|
|
85
|
+
institutions: RegistryInstitution[];
|
|
86
|
+
}
|
|
87
|
+
export interface CandidateMatch {
|
|
88
|
+
/** 3-digit 대표코드 (the target code when routed by a branch rule) */
|
|
89
|
+
institutionCode: string;
|
|
90
|
+
/** official 기관명 (the target name when routed by a branch rule) */
|
|
91
|
+
institutionName: string;
|
|
92
|
+
/** institution category: bank · non-bank · securities · clearing */
|
|
93
|
+
category: InstitutionCategory;
|
|
94
|
+
/** documented full account lengths for the institution */
|
|
95
|
+
accountLengths: number[];
|
|
96
|
+
/** evidence tokens satisfied for this input, e.g. '자릿수', '과목코드', '분기규칙' */
|
|
97
|
+
matched: string[];
|
|
98
|
+
/**
|
|
99
|
+
* Structural match strength: digits of subject-code evidence that matched
|
|
100
|
+
* (3 = near-unique front code, 2/1 = shared, 0 = length only). Higher ranks
|
|
101
|
+
* first. Branch-routed candidates get a high strength so they lead.
|
|
102
|
+
* Includes PROSPECTIVE front matches (a longer pattern's prefix fired while
|
|
103
|
+
* typing) — use for ranking only.
|
|
104
|
+
*/
|
|
105
|
+
strength: number;
|
|
106
|
+
/**
|
|
107
|
+
* Strength from EXACT-length signals only (spec.length === input length, or a
|
|
108
|
+
* fired branch rule). A 14-digit prefix firing on a 13-digit input contributes
|
|
109
|
+
* to `strength` (ranking) but NOT here — so a possibly-complete shorter input
|
|
110
|
+
* is never treated as "determined" by a longer pattern's signal.
|
|
111
|
+
*/
|
|
112
|
+
exactStrength: number;
|
|
113
|
+
/**
|
|
114
|
+
* Whether this institution COULD have signaled at this exact length (has a
|
|
115
|
+
* subject-code/prefix spec of this length, or a branch rule). If true and it
|
|
116
|
+
* didn't match, that is evidence AGAINST it; if false, absence of a match is
|
|
117
|
+
* no information — such institutions must not be evicted by others' matches.
|
|
118
|
+
*/
|
|
119
|
+
signalCapableAtLength: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Evidence grade — the narrowing policy in one word:
|
|
122
|
+
* 'matched' its signal fits these digits (kept, ranked first)
|
|
123
|
+
* 'unknown' no signal capability at this length (no information)
|
|
124
|
+
* 'contradicted' had codes at this length, none matched (demoted LAST, not
|
|
125
|
+
* dropped — code sets can't be proven exhaustive, 입금전용/신탁
|
|
126
|
+
* codes sprawl into the PDF's 비고 column)
|
|
127
|
+
*/
|
|
128
|
+
grade: 'matched' | 'unknown' | 'contradicted';
|
|
129
|
+
/** static PDF-transcribed flag; false where the PDF declares no check-digit verify */
|
|
130
|
+
checkDigitDeclared: boolean | null;
|
|
131
|
+
/** true when a document-declared branch rule definitively selected this institution */
|
|
132
|
+
routedByBranchRule: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* true when the institution declares a branch rule for exactly this input
|
|
135
|
+
* length (수협 11/12/14d). Even when the deciding digits didn't fire the rule,
|
|
136
|
+
* the PDF's "이외의 경우 007 SET" else-branch means this institution remains a
|
|
137
|
+
* documented possibility for this length — shortlists must not drop it.
|
|
138
|
+
*/
|
|
139
|
+
branchRuleAtLength: boolean;
|
|
140
|
+
/** institution priority (tie-break signal; higher = more common) */
|
|
141
|
+
priority: number;
|
|
142
|
+
/** institution logo/icon URL, empty string for now */
|
|
143
|
+
logo: string;
|
|
144
|
+
}
|
|
145
|
+
export interface AccountInstitutionMatchResult {
|
|
146
|
+
/** raw account number as provided */
|
|
147
|
+
input: string;
|
|
148
|
+
/** true when the normalized input length is a documented full length of some candidate */
|
|
149
|
+
isComplete: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Deterministically ordered candidate institutions.
|
|
152
|
+
* Sort: matched.length desc, then institutionName 가나다순.
|
|
153
|
+
* Empty for malformed / non-string / oversized input.
|
|
154
|
+
*/
|
|
155
|
+
candidates: CandidateMatch[];
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Match a raw account-number string against the institution registry.
|
|
159
|
+
* @returns AccountInstitutionMatchResult — never throws.
|
|
160
|
+
*/
|
|
161
|
+
export declare function matchAccount(raw: unknown, registry: Registry): AccountInstitutionMatchResult;
|
package/dist/match.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matching engine for Korean CMS account numbers.
|
|
3
|
+
*
|
|
4
|
+
* Accepts a raw account-number string (may be partial, hyphenated, or malformed)
|
|
5
|
+
* and the institution registry, and returns candidate deposit institutions each
|
|
6
|
+
* with a document-sourced matching-evidence array.
|
|
7
|
+
*
|
|
8
|
+
* Guarantees (per seed contract):
|
|
9
|
+
* - Never throws on any input value.
|
|
10
|
+
* - Empty candidates + isComplete=false for malformed/empty/non-digit/oversized
|
|
11
|
+
* strings and non-string values.
|
|
12
|
+
* - Document-based recall: an institution stays a candidate at every prefix until
|
|
13
|
+
* its documented lengths become unreachable (candidate set only shrinks).
|
|
14
|
+
* - Machine-executable definitive routing for document-declared branch rules
|
|
15
|
+
* (수협 007 ⇄ 030): once the deciding digits are present, the candidate is
|
|
16
|
+
* routed to the target institution.
|
|
17
|
+
* - No score/confidence fields. Deterministic sort: matched-count desc → name 가나다순.
|
|
18
|
+
* - Only internal normalization: strip hyphens/whitespace before matching.
|
|
19
|
+
*/
|
|
20
|
+
/** Strip hyphens/whitespace; null if empty or non-digit. */
|
|
21
|
+
function normalizeDigits(raw) {
|
|
22
|
+
if (typeof raw !== 'string')
|
|
23
|
+
return null;
|
|
24
|
+
const s = raw.replace(/[-\s]/g, '');
|
|
25
|
+
if (s.length === 0 || !/^\d+$/.test(s))
|
|
26
|
+
return null;
|
|
27
|
+
return s;
|
|
28
|
+
}
|
|
29
|
+
/** Does the examined digit window satisfy any of the rule's values/ranges? */
|
|
30
|
+
function branchMatches(digits, rule) {
|
|
31
|
+
const window = digits.slice(rule.position, rule.position + rule.matchLength);
|
|
32
|
+
if (window.length < rule.matchLength)
|
|
33
|
+
return false; // deciding digits not present yet
|
|
34
|
+
const v = Number(window);
|
|
35
|
+
if (Number.isNaN(v))
|
|
36
|
+
return false;
|
|
37
|
+
for (const m of rule.match) {
|
|
38
|
+
if (typeof m === 'number') {
|
|
39
|
+
if (v === m)
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
else if (v >= m.from && v <= m.to) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Match a raw account-number string against the institution registry.
|
|
50
|
+
* @returns AccountInstitutionMatchResult — never throws.
|
|
51
|
+
*/
|
|
52
|
+
export function matchAccount(raw, registry) {
|
|
53
|
+
const inputStr = typeof raw === 'string' ? raw : '';
|
|
54
|
+
const normalized = normalizeDigits(raw);
|
|
55
|
+
if (normalized === null)
|
|
56
|
+
return { input: inputStr, isComplete: false, candidates: [] };
|
|
57
|
+
const len = normalized.length;
|
|
58
|
+
const candidates = [];
|
|
59
|
+
for (const inst of registry.institutions) {
|
|
60
|
+
// candidate if some documented length is still reachable from the current input
|
|
61
|
+
if (!inst.accountLengths.some((l) => l >= len))
|
|
62
|
+
continue;
|
|
63
|
+
// '기관코드' = identity evidence; '자릿수' = a documented length is still reachable
|
|
64
|
+
const matched = ['기관코드', '자릿수'];
|
|
65
|
+
// Subject-code / prefix evidence. Two firing modes:
|
|
66
|
+
// • front specs (position 0): PROGRESSIVE — fire as soon as the prefix
|
|
67
|
+
// digits are typed, while the pattern length is still reachable
|
|
68
|
+
// (spec.length >= len). This is how real apps surface 카카오 at "3333…".
|
|
69
|
+
// • middle/end specs: fire only at the exact full pattern length (their
|
|
70
|
+
// position is meaningless until the number is complete).
|
|
71
|
+
// strength = codeLength (+0.5 front bonus) — a 3/4-digit front code is
|
|
72
|
+
// near-unique to one institution; shared 2/1-digit codes are weaker.
|
|
73
|
+
// Evidence only — never drops a candidate (recall stays intact).
|
|
74
|
+
let strength = 0;
|
|
75
|
+
let exactStrength = 0;
|
|
76
|
+
let signalCapableAtLength = false;
|
|
77
|
+
let evidenceToken = null;
|
|
78
|
+
for (const spec of inst.subjectCodes ?? []) {
|
|
79
|
+
if (spec.length === len)
|
|
80
|
+
signalCapableAtLength = true;
|
|
81
|
+
const end = spec.position + spec.codeLength;
|
|
82
|
+
if (spec.position === 0) {
|
|
83
|
+
if (spec.length < len || normalized.length < end)
|
|
84
|
+
continue; // unreachable or digits missing
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
if (spec.length !== len || normalized.length < end)
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const code = normalized.slice(spec.position, end);
|
|
91
|
+
if (spec.codes.includes(code)) {
|
|
92
|
+
let s = spec.codeLength + (spec.position === 0 ? 0.5 : 0);
|
|
93
|
+
// 가상계좌 코드는 순위 강도 절반 — 실계좌 매칭(국민 02 등)보다 아래로,
|
|
94
|
+
// 단 matched 등급과 리스트 잔류는 유지 (가상계좌도 유효한 이체 대상)
|
|
95
|
+
if (spec.virtual)
|
|
96
|
+
s *= 0.5;
|
|
97
|
+
if (s > strength) {
|
|
98
|
+
strength = s;
|
|
99
|
+
evidenceToken = spec.practical ? '프리픽스' : spec.virtual ? '가상계좌코드' : '과목코드';
|
|
100
|
+
}
|
|
101
|
+
// exact-length signals only — a longer pattern's prefix firing on a
|
|
102
|
+
// shorter (possibly complete) input must not count as determination
|
|
103
|
+
if (spec.length === len && s > exactStrength)
|
|
104
|
+
exactStrength = s;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (evidenceToken)
|
|
108
|
+
matched.push(evidenceToken);
|
|
109
|
+
// Branch routing: if a rule applies to the current length and its deciding
|
|
110
|
+
// digits are present and match, route the candidate to the target institution.
|
|
111
|
+
let institutionCode = inst.representativeCode;
|
|
112
|
+
let institutionName = inst.name;
|
|
113
|
+
let category = inst.category ?? 'bank';
|
|
114
|
+
let routedByBranchRule = false;
|
|
115
|
+
let branchRuleAtLength = false;
|
|
116
|
+
for (const rule of inst.branchRules) {
|
|
117
|
+
if (rule.length !== len)
|
|
118
|
+
continue;
|
|
119
|
+
branchRuleAtLength = true;
|
|
120
|
+
if (branchMatches(normalized, rule)) {
|
|
121
|
+
institutionCode = rule.targetCode;
|
|
122
|
+
institutionName = rule.targetName;
|
|
123
|
+
// the candidate now IS the target institution — carry its category
|
|
124
|
+
// (수협중앙회 is non-bank while 수협은행 is bank)
|
|
125
|
+
category =
|
|
126
|
+
registry.institutions.find((t) => t.representativeCode === rule.targetCode)
|
|
127
|
+
?.category ?? category;
|
|
128
|
+
routedByBranchRule = true;
|
|
129
|
+
matched.push('분기규칙');
|
|
130
|
+
// a definitive document rule is the strongest possible structural signal
|
|
131
|
+
// (branch rules only fire at their exact length, so exactStrength too)
|
|
132
|
+
strength = Math.max(strength, 4);
|
|
133
|
+
exactStrength = Math.max(exactStrength, 4);
|
|
134
|
+
}
|
|
135
|
+
// rule exists for this length but digits don't match → stays source institution
|
|
136
|
+
}
|
|
137
|
+
candidates.push({
|
|
138
|
+
institutionCode,
|
|
139
|
+
institutionName,
|
|
140
|
+
category,
|
|
141
|
+
accountLengths: inst.accountLengths,
|
|
142
|
+
matched,
|
|
143
|
+
strength,
|
|
144
|
+
exactStrength,
|
|
145
|
+
signalCapableAtLength: signalCapableAtLength || branchRuleAtLength,
|
|
146
|
+
grade: strength > 0 || branchRuleAtLength
|
|
147
|
+
? 'matched'
|
|
148
|
+
: signalCapableAtLength
|
|
149
|
+
? 'contradicted'
|
|
150
|
+
: 'unknown',
|
|
151
|
+
checkDigitDeclared: inst.checkDigitDeclared,
|
|
152
|
+
routedByBranchRule,
|
|
153
|
+
branchRuleAtLength,
|
|
154
|
+
priority: typeof inst.priority === 'number' ? inst.priority : 0,
|
|
155
|
+
logo: typeof inst.logo === 'string' ? inst.logo : '',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// Sort: match strength desc (branch rule > front 3-digit code > 2/1-digit code
|
|
159
|
+
// > length only) → matched-count desc → priority desc (major banks first at a
|
|
160
|
+
// tie) → institutionName 가나다순.
|
|
161
|
+
candidates.sort((a, b) => {
|
|
162
|
+
if (b.strength !== a.strength)
|
|
163
|
+
return b.strength - a.strength;
|
|
164
|
+
if (b.matched.length !== a.matched.length)
|
|
165
|
+
return b.matched.length - a.matched.length;
|
|
166
|
+
if (b.priority !== a.priority)
|
|
167
|
+
return b.priority - a.priority;
|
|
168
|
+
return a.institutionName.localeCompare(b.institutionName, 'ko');
|
|
169
|
+
});
|
|
170
|
+
const isComplete = candidates.some((c) => c.accountLengths.includes(len));
|
|
171
|
+
return { input: inputStr, isComplete, candidates };
|
|
172
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "korean-bank-detect",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Korean bank account number detection via 금결원 CMS 계좌번호체계",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./registry": "./data/registry.json"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"build": "node scripts/build-registry.mjs && tsc",
|
|
18
|
+
"generate": "node scripts/build-registry.mjs",
|
|
19
|
+
"prepublishOnly": "npm run build && npm test"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"korean-account": "^0.1.2",
|
|
23
|
+
"typescript": "^5.5.0",
|
|
24
|
+
"vitest": "^2.0.0"
|
|
25
|
+
},
|
|
26
|
+
"allowScripts": {
|
|
27
|
+
"esbuild@0.21.5": true,
|
|
28
|
+
"fsevents@2.3.3": true
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"data/registry.json",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"keywords": [
|
|
37
|
+
"korean",
|
|
38
|
+
"bank",
|
|
39
|
+
"account",
|
|
40
|
+
"계좌번호",
|
|
41
|
+
"은행",
|
|
42
|
+
"금융결제원",
|
|
43
|
+
"cms",
|
|
44
|
+
"fintech"
|
|
45
|
+
],
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/LESANF/korean-bank-detect.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/LESANF/korean-bank-detect#readme",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/LESANF/korean-bank-detect/issues"
|
|
54
|
+
},
|
|
55
|
+
"author": "LESANF",
|
|
56
|
+
"sideEffects": false
|
|
57
|
+
}
|