Why 45992700 Is Not 459927: Longest-Prefix-First BIN Matching for Korean Cards

Here is a small quiz. A user in Seoul types a card number that starts with 4599 2700. Your card-type detector looks at it and says, confidently: Visa.

Is it wrong? No. Is it useful? Also no.

In the Korean payments context, "Visa" is barely the beginning of the question. Which issuer — 신한카드? 현대카드? Is it a credit card (신용) or a check card (체크)? Personal (개인) or corporate (법인)? Fee policies, promotions, and plain old UI copy ("현대카드 결제일 할인!") all hang off answers that a brand detector cannot give you. And some purely domestic 로컬 cards don't match any international brand pattern — feed my dataset's BIN 200001 (a 신한카드 로컬 card) to a global detector and you get back an empty list and a shrug.

So I built credit_card_type_detector_korean, a Dart package that answers the Korean half of the question. This post is about the two decisions that turned out to matter: matching longest prefix first, and returning a list instead of a single answer. Both were forced on me by the data, which is my favorite way to make a design decision. (The data doesn't argue back. Mostly.)

Two layers, two kinds of knowledge

Brand detection is pattern knowledge: "Visa starts with 4" is a rule you can write as a regex, and the excellent upstream credit_card_type_detector package already does it well. My package builds on it as a dependency rather than reinventing it.

Issuer detection is table knowledge. There is no rule that derives "현대카드, 체크, 법인" from digits — there is only the domestic BIN table, maintained by the Korean VAN ecosystem, that says so. The version bundled in the package is generated from the KICC BIN table and currently holds 3,643 entries: 3,280 six-digit BINs and 363 eight-digit ones (dataset cut 2026-04-28).

That "six-digit and eight-digit, mixed" detail is where the fun starts.

The bug I would have shipped

My first instinct was the obvious one: index every BIN in a map, take the card number's first six digits, look them up. Clean, O(1), done by lunch.

Then I looked at what the table actually contains:

  • 459927 → 신한카드 (비자)
  • 45992700 → 현대카드 (비자, 체크, 법인)

These are both real rows in the dataset. The eight-digit BIN is not a refinement of the six-digit one — it belongs to a different issuer. A six-digit-first lookup takes card number 4599270012345678, matches 459927, and cheerfully reports 신한카드 for a 현대카드 card. No error, no warning, just a wrong issuer in production while every test with a "normal" card passes.

The fix is a rule old enough to have grandchildren — routers have been doing longest-prefix matching since before I was born. Try the most specific prefix first:

/// Distinct BIN lengths present in the index, sorted descending.
final List<int> _binLengths =
    _binIndex.keys.map((k) => k.length).toSet().toList()
      ..sort((a, b) => b.compareTo(a));

List<CardBinModel> detect(String cardNumber) {
  final sanitized = cardNumber.replaceAll(_nonDigit, '');
  if (sanitized.length < 6) return [];

  // Longest-prefix-first: try each BIN length in descending order.
  // Returns the first match found, which corresponds to the most specific BIN.
  for (final len in _binLengths) {
    if (sanitized.length < len) continue;
    final matches = _binIndex[sanitized.substring(0, len)];
    if (matches != null) return List.unmodifiable(matches);
  }
  return [];
}

Two things I'd defend in review:

The lengths are enumerated from the data, not hard-coded. _binLengths is computed from the index keys. Today that means [8, 6]; if a future table revision introduces seven-digit BINs, the loop becomes 8 → 7 → 6 with zero code changes. The dataset is regenerated from a source file on every update, so I genuinely do not control what lengths show up next.

The 459927 case is a regression test now. It's the exact card-number shape that silently passes under naive matching, so it's pinned in the test suite with both the six-digit and eight-digit expectations. If a refactor ever reorders the lookup, that test fails before a 현대카드 user does.

The collision that designed my API

While auditing the dataset I found exactly one BIN that appears twice: 941048, listed once for 하나카드 and once for 카카오페이 체크. Same prefix, two rows, no tiebreaker in the data.

This is why detect() returns List<CardBinModel> and not CardBinModel?. I'd love to tell you that was foresight; actually the plan said "multiple issuers can share a BIN in edge cases; the caller picks," and then the data went ahead and contained the edge case. The list return type is load-bearing, not defensive. If your API returns "the" answer for a lookup that the underlying data defines as one-to-many, the lie will eventually surface as a support ticket.

The combined entry point keeps the same honesty across both layers:

CardDetectionResult detectCard(String cardNumber) {
  return CardDetectionResult(
    koreanBins: detect(cardNumber),
    internationalTypes: List.unmodifiable(detectCCType(cardNumber)),
  );
}

detectCard() fans out to the Korean table and the upstream brand detector and returns both lists side by side — no ranking, no merging, no pretending the two layers agree on anything. The caller knows their product context; I don't.

One more habit worth naming: BINs are strings everywhere — in the model, in the index keys, in the generator output. A BIN is an identifier that happens to be made of digits, not a number. Parse it to int and you've signed up for leading-zero bugs the day the table includes one. The current table happens to contain none, which is exactly the kind of fact I refuse to build a type system on.

The part where a human downloads a spreadsheet

The dataset's supply chain is gloriously unglamorous. A human (me) downloads the current BIN table spreadsheet from the KICC VAN support page, exports the detail sheet as CSV, and drops it in the repo. From there, machines take over: dart tool/generate_data.dart finds the CSV, regenerates lib/src/data.dart, and stamps a datasetVersion from the date in the filename.

I kept the human step deliberately. The upstream file is an .xls on a vendor support page, not an API; pretending otherwise with a scraper would just move the breakage somewhere I'd notice later. What I didn't keep is trust in myself: as of 0.2.0, CI regenerates the data from the committed CSV on every run and fails if the output differs from what's checked in. Hand-edit the generated file, or let it drift from its source, and the build goes red. The one supply-chain failure mode I can automate away is "the artifact quietly stopped matching its source," so that one is gone.

And because a BIN table is milk, not wine, the package exports the expiry information:

const datasetVersion = '2026-04-28';

Your app can check how stale its bundled copy is and decide for itself how nervous to be.

Lessons learned

  1. Let the data veto your design. Both load-bearing decisions here — longest-prefix-first, list-shaped returns — were forced by actual rows (45992700, 941048 twice), not by speculation. Audit the dataset before you design the API for it.
  2. The dangerous bug is the one that returns an answer. Naive prefix matching doesn't crash; it returns the wrong issuer with full confidence. Pin the counterexample as a regression test.
  3. Identifiers made of digits are still strings. The moment you int.parse a BIN, ZIP code, or account number, you're betting there will never be a leading zero. That's not your bet to make.
  4. Automate the drift check, keep the human at the boundary. A manual download step is honest about what the upstream is. A CI gate that regenerates-and-diffs makes sure honesty is the only thing that's manual.

The package is on pub.dev as credit_card_type_detector_korean (0.2.0 as of July 2026). If you work with Korean payment data and the table has surprised you in some other way — I collect these now — tell me in the comments.