210 lines
6.8 KiB
TypeScript
210 lines
6.8 KiB
TypeScript
/**
|
|
* 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<string | null> {
|
|
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<boolean> {
|
|
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<string, string> = {
|
|
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
|
|
}
|
|
}
|