self::nominatim($zip), 'google' => self::google($zip), default => self::zippopotam($zip), }; } catch (Throwable $e) { Log::warn('Geocode provider failed', ['provider' => $provider, 'error' => $e->getMessage()]); } // ZIP centroids do not change: cache for 30 days (failures for 10 minutes). Db::cacheSet($cacheKey, json_encode($result ?? []), $result ? 30 * 86400 : 600); return $result; } private static function zippopotam(string $zip): ?array { $base = rtrim(Config::get('GEOCODE_BASE_URL_OVERRIDE') ?: 'https://api.zippopotam.us', '/'); // override is for the test-suite only $r = Http::request('GET', "$base/us/$zip", null, ['Accept' => 'application/json'], 6); $j = $r['json']; if ($r['status'] !== 200 || !is_array($j) || empty($j['places'][0])) { return null; } $p = $j['places'][0]; return self::result((float)$p['latitude'], (float)$p['longitude'], $p['place name'] ?? null, $p['state abbreviation'] ?? null); } private static function nominatim(string $zip): ?array { $r = Http::request('GET', "https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&countrycodes=us&postalcode=$zip", null, ['Accept' => 'application/json'], 6); $j = $r['json']; if ($r['status'] !== 200 || !is_array($j) || empty($j[0]['lat'])) { return null; } return self::result((float)$j[0]['lat'], (float)$j[0]['lon'], null, null); } private static function google(string $zip): ?array { $key = Config::require('GEOCODE_API_KEY'); $r = Http::request('GET', "https://maps.googleapis.com/maps/api/geocode/json?components=postal_code:$zip|country:US&key=" . rawurlencode($key), null, [], 6); $j = $r['json']; if ($r['status'] !== 200 || !is_array($j) || ($j['status'] ?? '') !== 'OK') { return null; } $loc = $j['results'][0]['geometry']['location'] ?? null; if (!$loc) { return null; } $city = null; $state = null; foreach ($j['results'][0]['address_components'] ?? [] as $c) { if (in_array('locality', $c['types'] ?? [], true)) { $city = $c['long_name']; } if (in_array('administrative_area_level_1', $c['types'] ?? [], true)) { $state = $c['short_name']; } } return self::result((float)$loc['lat'], (float)$loc['lng'], $city, $state); } private static function result(float $lat, float $lng, ?string $city, ?string $state): ?array { if (($lat === 0.0 && $lng === 0.0) || abs($lat) > 90 || abs($lng) > 180) { return null; } return ['lat' => round($lat, 4), 'lng' => round($lng, 4), 'city' => $city ? Validator::clean($city, 60) : null, 'state' => $state ? Validator::clean($state, 20) : null]; } }