/** * Validates a Swedish Personnummer using the Modulo 10 (Luhn) algorithm. * Now also verifies that the date component is a valid calendar date. */ export function validatePersonnummer(pin: string): boolean { const cleanPin = pin.replace(/\D/g, ''); // 1. Length Check (12 digits: YYYYMMDDXXXX) if (cleanPin.length !== 12) return false; // 2. Date Check (YYYYMMDD) const year = parseInt(cleanPin.substring(0, 4)); const month = parseInt(cleanPin.substring(4, 6)); const day = parseInt(cleanPin.substring(6, 8)); if (month < 1 || month > 12) return false; const date = new Date(year, month - 1, day); if ( date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day ) { return false; } // 3. Luhn Check (on the last 10 digits) const pin10 = cleanPin.substring(2); let sum = 0; for (let i = 0; i < 10; i++) { let val = parseInt(pin10[i]); if (i % 2 === 0) { val *= 2; if (val > 9) val -= 9; } sum += val; } return sum % 10 === 0; } /** * Validates a Swedish address format (Street Name + Number) */ export function validateAddress(address: string): boolean { // Expects "Streetname 12" or "Street 1B", etc. const addressRegex = /^.{2,}\s\d+[a-zA-Z]?.*$/; return addressRegex.test(address.trim()); } /** * Fetches Swedish city data for a given 5-digit postcode. * Uses the free Zippopotam.us API. */ export async function lookupSwedishCity(zipCode: string): Promise { const cleanZip = zipCode.replace(/\D/g, ''); if (cleanZip.length !== 5) return null; try { const response = await fetch(`https://api.zippopotam.us/se/${cleanZip}`); if (!response.ok) return null; const data = await response.json(); if (data.places && data.places.length > 0) { return data.places[0]['place name']; } return null; } catch (error) { console.error('Postcode lookup error:', error); return null; } } /** * Normalizes a Swedish address to increase map matching success. * Strips apartment numbers, floor info, and standardizes spacing. * Example: "Storgatan 12, lgh 1201" -> "Storgatan 12" */ export function normalizeSwedishAddress(address: string): string { // 1. Remove common Swedish apartment/floor prefixes and everything after them // (lgh, vån, trappa, tr, oppgång, etc.) let normalized = address.toLowerCase(); const suffixes = [ ',', ' lgh', ' vån', ' tr', ' trappa', ' nr', ' uppgång', ' floor', ' apt', ' suite', ' bvx', ' box' ]; for (const suffix of suffixes) { const index = normalized.indexOf(suffix); if (index !== -1) { normalized = normalized.substring(0, index); } } // 2. Standardize house numbers (ensure space before letters, e.g. "12A" -> "12 A") // Note: Nominatim handles "12A" well, but standardizing can help some older data. // However, the most important part is removing the "noise" after the number. return normalized.trim(); } /** * Normalizes a Swedish mobile number to the format 467XXXXXXXX. * Handles +, 00, 07 prefix and whitespace/hyphens. */ export function normalizeSwedishMobile(mobile: string): string { // 1. Remove all non-numeric characters let clean = mobile.replace(/\D/g, ''); // 2. Handle international prefixes if (clean.startsWith('0046')) { clean = clean.substring(2); } else if (clean.startsWith('07')) { // Change 07... to 467... clean = '46' + clean.substring(1); } // Note: If it already starts with 467, we leave it as is. // Minimal length for a Swedish mobile is 46 + 9 digits = 11 digits return clean; } /** * Validates if the string is a valid Swedish mobile number. * Allows various formats but normalizes them first. */ export function validateSwedishMobile(mobile: string): boolean { const normalized = normalizeSwedishMobile(mobile); // Swedish mobile numbers (including 46 prefix) are typically 11 digits // Format: 46 7X XXX XX XX const mobileRegex = /^467\d{8}$/; return mobileRegex.test(normalized); } /** * Verifies if a specific street address exists in a given Swedish city/zip. * Uses Nominatim (OpenStreetMap) API with a multi-fallback strategy. */ export async function lookupSwedishAddress(street: string, zipCode: string, city: string): Promise { const cleanZip = zipCode.replace(/\D/g, ''); const normalizedStreet = normalizeSwedishAddress(street); // Helper to call Nominatim with varying parameters const fetchFromNominatim = async (s: string, useCity: boolean) => { const params: Record = { street: s, postalcode: cleanZip, country: 'Sweden', format: 'json', addressdetails: '1', // Get detailed address info limit: '5' // Check a few results in case the first one is a different zip }; if (useCity) params.city = city; const urlParams = new URLSearchParams(params); try { const response = await fetch(`https://nominatim.openstreetmap.org/search?${urlParams.toString()}`, { headers: { 'User-Agent': 'NordicStorium-App-Validation' } }); if (!response.ok) return null; return await response.json(); } catch { return null; } }; interface NominatimResult { address?: { postcode?: string; }; } // Helper to verify that at least one result matches the input zip code const hasZipMatch = (data: NominatimResult[]) => { if (!data || data.length === 0) return false; // Check if any of the results have a matching postcode (handling spaces like "123 45") return data.some(item => { const resultZip = (item.address?.postcode || '').replace(/\s/g, ''); return resultZip === cleanZip; }); }; try { // Attempt 1: Normalized Exactly (Street + Number) + Zip const try1 = await fetchFromNominatim(normalizedStreet, false); if (hasZipMatch(try1)) return true; // Attempt 2: Street Name only + Zip const streetNameMatch = normalizedStreet.match(/^([^\d]+)/); if (streetNameMatch) { const streetName = streetNameMatch[1].trim(); if (streetName.length > 2) { const try2 = await fetchFromNominatim(streetName, false); if (hasZipMatch(try2)) return true; } } // Attempt 3: Include City (last resort) const try3 = await fetchFromNominatim(normalizedStreet, true); if (hasZipMatch(try3)) return true; return false; } catch { return true; // Fail open } }