/** * IBAN validation (ISO 13616): character set, per-country length, MOD 97-10 * check digits. Pure, dependency-free — used by the public signing/SEPA portal * (client) and re-checked by the server routes (import via ~~/app/utils/iban). */ /** IBAN length per country (ISO 13616 registry, SEPA + common non-SEPA). */ export const IBAN_LENGTHS: Record = { AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28, BA: 20, BE: 16, BG: 22, BH: 22, BR: 29, BY: 28, CH: 21, CR: 22, CY: 28, CZ: 24, DE: 22, DK: 18, DO: 28, EE: 20, EG: 29, ES: 24, FI: 18, FO: 18, FR: 27, GB: 22, GE: 22, GI: 23, GL: 18, GR: 27, GT: 28, HR: 21, HU: 28, IE: 22, IL: 23, IQ: 23, IS: 26, IT: 27, JO: 30, KW: 30, KZ: 20, LB: 28, LC: 32, LI: 21, LT: 20, LU: 20, LV: 21, LY: 25, MC: 27, MD: 24, ME: 22, MK: 19, MR: 27, MT: 31, MU: 30, NL: 18, NO: 15, PK: 24, PL: 28, PS: 29, PT: 25, QA: 29, RO: 24, RS: 22, SA: 24, SC: 31, SD: 18, SE: 24, SI: 19, SK: 24, SM: 27, ST: 25, SV: 28, TL: 23, TN: 24, TR: 26, UA: 29, VA: 22, VG: 24, XK: 20 } /** SEPA scheme countries (EU/EEA + CH, GB, MC, SM, VA, AD, …). */ export const SEPA_COUNTRIES = new Set([ 'AD', 'AT', 'BE', 'BG', 'CH', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GB', 'GI', 'GR', 'HR', 'HU', 'IE', 'IS', 'IT', 'LI', 'LT', 'LU', 'LV', 'MC', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', 'SM', 'VA' ]) export type IbanReason = 'empty' | 'chars' | 'country' | 'length' | 'checksum' | 'ok' export interface IbanResult { valid: boolean /** Upper-case, no spaces. */ normalized: string /** Groups of four for display. */ formatted: string country: string sepa: boolean reason: IbanReason expectedLength: number | null } export const normalizeIban = (raw: any): string => String(raw ?? '').replace(/\s+/g, '').toUpperCase() export const formatIbanDisplay = (raw: any): string => normalizeIban(raw).replace(/(.{4})/g, '$1 ').trim() /** MOD 97-10: move the first four chars to the end, letters → 10..35, remainder must be 1. */ const mod97 = (iban: string): number => { const rearranged = iban.slice(4) + iban.slice(0, 4) let remainder = 0 for (const ch of rearranged) { const v = ch >= 'A' && ch <= 'Z' ? String(ch.charCodeAt(0) - 55) : ch for (const d of v) remainder = (remainder * 10 + Number(d)) % 97 } return remainder } export const validateIban = (raw: any): IbanResult => { const normalized = normalizeIban(raw) const country = normalized.slice(0, 2) const base: Omit = { normalized, formatted: formatIbanDisplay(normalized), country, sepa: SEPA_COUNTRIES.has(country), expectedLength: IBAN_LENGTHS[country] ?? null } if (!normalized) return { ...base, valid: false, reason: 'empty' } if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(normalized)) return { ...base, valid: false, reason: 'chars' } if (!IBAN_LENGTHS[country]) return { ...base, valid: false, reason: 'country' } if (normalized.length !== IBAN_LENGTHS[country]) return { ...base, valid: false, reason: 'length' } if (mod97(normalized) !== 1) return { ...base, valid: false, reason: 'checksum' } return { ...base, valid: true, reason: 'ok' } } /** Human message for a validation reason (DE/EN). */ export const ibanReasonText = (r: IbanResult, lang: 'de' | 'en' = 'de'): string => { const de = lang === 'de' switch (r.reason) { case 'ok': return '' case 'empty': return de ? 'Bitte IBAN eingeben.' : 'Please enter the IBAN.' case 'chars': return de ? 'IBAN darf nur Buchstaben und Ziffern enthalten und muss mit dem Länderkürzel beginnen (z. B. DE…).' : 'The IBAN may only contain letters and digits and must start with the country code (e.g. DE…).' case 'country': return de ? `Unbekanntes Länderkürzel „${r.country}“.` : `Unknown country code “${r.country}”.` case 'length': return de ? `Eine ${r.country}-IBAN hat ${r.expectedLength} Zeichen (eingegeben: ${r.normalized.length}).` : `A ${r.country} IBAN has ${r.expectedLength} characters (entered: ${r.normalized.length}).` case 'checksum': return de ? 'Die Prüfziffer stimmt nicht – bitte IBAN prüfen (Zahlendreher?).' : 'The check digits do not match – please verify the IBAN (transposed digits?).' } } /** BIC/SWIFT: 8 or 11 chars (optional in SEPA since 2016 — validated only when given). */ export const validateBic = (raw: any): boolean => { const v = String(raw ?? '').replace(/\s+/g, '').toUpperCase() return !v || /^[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?$/.test(v) }