Step 1: one formatter that knows each brand
lib/card.ts
export type Brand = "visa" | "mastercard" | "amex" | "discover" | "diners" | "jcb" | "unionpay" | "unknown";
type Rule = { brand: Exclude<Brand, "unknown">; test: RegExp; gaps: number[]; lengths: number[]; cvc: number };
// Order matters: the first rule whose prefix test matches wins.
const RULES: Rule[] = [
{ brand: "amex", test: /^3[47]/, gaps: [4, 10], lengths: [15], cvc: 4 },
{ brand: "diners", test: /^3(0[0-5]|[689])/, gaps: [4, 10], lengths: [14, 16, 19], cvc: 3 },
{ brand: "jcb", test: /^35(2[89]|[3-8])/, gaps: [4, 8, 12], lengths: [16, 17, 18, 19], cvc: 3 },
{ brand: "visa", test: /^4/, gaps: [4, 8, 12], lengths: [13, 16, 19], cvc: 3 },
{ brand: "mastercard", test: /^(5[1-5]|222[1-9]|22[3-9]|2[3-6]|27[01]|2720)/, gaps: [4, 8, 12], lengths: [16], cvc: 3 },
{ brand: "discover", test: /^(6011|64[4-9]|65)/, gaps: [4, 8, 12], lengths: [16, 19], cvc: 3 },
{ brand: "unionpay", test: /^62/, gaps: [4, 8, 12], lengths: [16, 17, 18, 19], cvc: 3 },
];
const FALLBACK = { gaps: [4, 8, 12], lengths: [16, 19], cvc: 3 };
export const digitsOnly = (s: string) => s.replace(/\D/g, "");
export function cardInfo(digits: string) {
const rule = RULES.find((r) => r.test.test(digits));
return rule ? { ...rule } : { brand: "unknown" as const, ...FALLBACK };
}
// "378282246310005" -> "3782 822463 10005" (Amex 4-6-5); everything else in groups of 4.
export function formatCard(raw: string) {
const info = cardInfo(digitsOnly(raw));
const digits = digitsOnly(raw).slice(0, Math.max(...info.lengths));
// Diners is 4-6-4 at 14 digits; its 16-digit cards group like Visa.
const gaps = info.brand === "diners" && digits.length > 14 ? [4, 8, 12] : info.gaps;
let out = "";
for (let i = 0; i < digits.length; i++) {
if (gaps.includes(i)) out += " ";
out += digits[i];
}
return { value: out, digits, brand: info.brand, maxDigits: Math.max(...info.lengths) };
}
// Luhn: double every second digit from the right, subtract 9 when it goes over 9.
export function luhn(digits: string) {
if (!/^\d+$/.test(digits)) return false;
let sum = 0;
for (let i = 0; i < digits.length; i++) {
let d = Number(digits[digits.length - 1 - i]);
if (i % 2 === 1) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
}
return sum % 10 === 0;
}
export function isCompleteCard(digits: string) {
const info = cardInfo(digits);
return info.lengths.includes(digits.length) && luhn(digits);
}