The bug that never shows up in your logs
Most card-brand detection code is old. Someone wrote if (/^5[1-5]/.test(pan)) return 'mastercard' years ago and it shipped. It still works for most cards. It also rejects a valid class of Mastercard numbers, and you will probably never see an error for it.
Since 2017, Mastercard has issued cards in the 222100–272099 range in addition to the classic 51–55 prefixes. If your checkout form, gateway plugin, fraud rules, or analytics pipeline classifies brands by leading digits, it needs to handle both ranges. For the business background, including the official range, the 2017 acceptance assessments, and an end-to-end test plan, see Mastercard 2-Series BINs: The Official Range, What the Fines Were & How to Test Your Checkout.
This article covers one narrow piece: the classification logic itself. It walks through how to write it, how it typically goes wrong, and how to test it so it can't regress.
Why this failure is silent
Trace the data flow for a card that starts with 2223:
- The user types the number into the card field.
- Client-side code runs Luhn, then brand detection.
- Brand detection returns
unknown. - The form shows "Invalid card number" and disables submit.
- Nothing is sent to the server. There is no authorization request and no decline code. The processor dashboard shows nothing.
The same pattern repeats further down the stack. A fraud rule that treats unknown brand as high risk blocks the order. An analytics job that buckets cards by first digit puts these transactions in an "other" category. Each layer fails locally and reports nothing upstream.
That is why the fix has two parts: correct classification, and instrumentation on rejection paths.
Use numeric ranges, not regex
The 2-series range is a contiguous numeric interval. Regex is a poor fit for intervals, which is how most bugs get in. Compare the leading digits as an integer against explicit bounds instead.
type Brand = 'visa' | 'mastercard' | 'amex' | 'unknown';
interface PrefixRange {
brand: Brand;
length: number; // how many leading digits to compare
min: number;
max: number; // inclusive
}
// Ordered most-specific first. Extend for the brands you accept.
const RANGES: PrefixRange[] = [
{ brand: 'mastercard', length: 4, min: 2221, max: 2720 },
{ brand: 'mastercard', length: 2, min: 51, max: 55 },
{ brand: 'amex', length: 2, min: 34, max: 34 },
{ brand: 'amex', length: 2, min: 37, max: 37 },
{ brand: 'visa', length: 1, min: 4, max: 4 },
];
export function detectBrand(rawPan: string): Brand {
const pan = rawPan.replace(/[\s-]/g, '');
if (!/^\d+$/.test(pan)) return 'unknown';
for (const r of RANGES) {
if (pan.length < r.length) continue;
const prefix = Number(pan.slice(0, r.length));
if (prefix >= r.min && prefix <= r.max) return r.brand;
}
return 'unknown';
}
Note the 4-digit bounds. A 6-digit range of 222100–272099 is equivalent to a 4-digit range of 2221–2720, because every 6-digit value from 272000 to 272099 starts with 2720. Using four digits means the brand resolves earlier while the user is typing, so the card icon appears sooner.
The range table is data, not code. You can load it from config, version it, and review changes in a diff without parsing a regex in your head.
If you must use regex
Some form libraries only accept a pattern. The correct regex for 2221–2720 is:
^(222[1-9]|22[3-9]\d|2[3-6]\d{2}|27[01]\d|2720)
Combined with the classic range:
^(5[1-5]|222[1-9]|22[3-9]\d|2[3-6]\d{2}|27[01]\d|2720)
It is easy to get one alternation wrong here. That is the main argument for range comparison.
Common mistakes and how to catch them
| Mistake | Symptom | How to catch it |
|---|---|---|
^5[1-5] only |
All 2-series cards rejected | Boundary test at 2221 and 2720 |
^2[2-7] (too broad) |
Non-Mastercard numbers starting 22xx/27xx labeled Mastercard | Negative tests at 2220 and 2721 |
^2[2-7][0-9]{2} with an off-by-one |
Either 2221 or 2720 misclassified | Explicit min/max boundary cases |
Lexicographic string compare (pan >= '2221') |
'3' > '2720' is true, so unrelated prefixes match |
Compare fixed-length numeric slices only |
| Brand derived from first digit in analytics | 2-series counted as "other" or "airline" | Reconcile brand counts against processor reports |
unknown brand mapped to decline in fraud rules |
Valid cards blocked post-submit | Rule review plus a 2-series test transaction |
| BIN column stored as 6 digits | Lookups break after 8-digit BIN adoption | Schema check: BIN fields should hold 8 digits |
The too-broad case matters. The prefix 22 is not Mastercard's alone. Other networks have been allocated ranges that start with 2. If your code labels anything starting with 22 as Mastercard, you will send the wrong brand to downstream systems. Match the exact interval.
Don't forget the 8-digit BIN change
The ISO/IEC 7812-1 standard extended the issuer identification number from six digits to eight, and card networks adopted it in 2022. Brand detection still works on the shorter 2- or 4-digit prefixes above. BIN lookup tables are a different matter. A table keyed on 6 digits can still resolve a brand, but it may return the wrong issuer, product, or country for an 8-digit range.
Check these:
- Database columns and API fields that store a "BIN": are they at least 8 characters?
- Lookup code: does it try 8 digits first, then fall back to 6?
- Data retention: can you legally store 8 leading digits? PCI DSS guidance on truncation changed alongside 8-digit BINs. Confirm the current rules with your QSA before you widen a stored field.
Testing: generate boundary numbers instead of hardcoding them
Processor sandboxes publish 2-series test cards. Stripe's, for example, is 2223003122003222. Use them for integration tests against that processor. For unit tests on your own classifier, generate Luhn-valid numbers at each boundary so the suite covers the edges directly.
function luhnCheckDigit(partial: string): number {
let sum = 0;
// Walk right-to-left; the check digit will be appended, so
// the rightmost digit of `partial` is doubled.
for (let i = 0; i < partial.length; i++) {
let d = Number(partial[partial.length - 1 - i]);
if (i % 2 === 0) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
}
return (10 - (sum % 10)) % 10;
}
function makePan(prefix: string, length = 16): string {
const body = prefix.padEnd(length - 1, '0');
return body + luhnCheckDigit(body);
}
describe('detectBrand: Mastercard 2-series boundaries', () => {
const cases: [string, string][] = [
['2220', 'unknown'], // just below range
['2221', 'mastercard'], // lower bound
['2500', 'mastercard'], // interior
['2720', 'mastercard'], // upper bound
['2721', 'unknown'], // just above range
['50', 'unknown'],
['51', 'mastercard'],
['55', 'mastercard'],
['56', 'unknown'],
];
test.each(cases)('prefix %s -> %s', (prefix, expected) => {
const pan = makePan(prefix);
expect(isLuhnValid(pan)).toBe(true); // sanity check
expect(detectBrand(pan)).toBe(expected);
});
test('known sandbox card', () => {
expect(detectBrand('2223 0031 2200 3222')).toBe('mastercard');
});
});
A 'unknown' expectation only holds if no other range in your table covers that prefix. If you add brands, update the negative cases.
For broader coverage, add a property-based test. Generate random integers in [2221, 2720], build Luhn-valid PANs, and assert they always classify as Mastercard. Then do the same just outside the bounds.
Test every layer that classifies
Unit tests only cover the function you wrote. List every component that reads leading digits and give each one its own check:
- Client form or hosted fields: run the boundary cases in an end-to-end browser test that types each number and asserts submit is enabled.
- Gateway plugin: if it does its own validation, test it separately. It may not call your function.
- Server-side validation: the same boundary suite, run against the API.
-
Fraud and routing rules: feed a 2-series test PAN through rule evaluation and assert it isn't routed to
unknown. -
Analytics and reporting: assert that brand derivation in your ETL produces
mastercardfor a 2-series fixture.
The whole path still needs a real transaction in production. Sandbox and unit tests can't prove that your acquirer configuration accepts the card.
Instrument the rejection path
Because client-side rejections never reach a server, add a lightweight event:
if (brand === 'unknown' && isLuhnValid(pan)) {
track('card_brand_unrecognized', {
prefix4: pan.slice(0, 4), // never send the full PAN
length: pan.length,
});
}
A Luhn-valid number with an unrecognized brand is a strong signal that your range table is incomplete. Alert when this event's rate rises. Review your data-handling policy before logging any part of a PAN, and keep the prefix as short as is useful.
Checklist
- [ ] Brand detection uses explicit numeric ranges, including 2221–2720
- [ ] Boundary tests cover 2220, 2221, 2720, 2721, 50, 51, 55, 56
- [ ] Gateway plugins and hosted fields tested independently
- [ ] Fraud rules don't treat
unknownbrand as an automatic decline without review - [ ] BIN storage and lookup support 8 digits
- [ ] Client-side rejections of Luhn-valid numbers are tracked
- [ ] One live 2-series transaction has cleared through the full stack
Top comments (0)