# /opt/docker/dev/service_finder/backend/app/services/nav_service.py """ NAV Online Számla API v3 integráció – queryTaxpayer végpont. Hivatalos magyar adószám alapú cégadat-lekérdezés XML/SOAP alapon. """ import uuid import hashlib import logging from datetime import datetime, timezone from typing import Optional, Dict, Any import httpx from app.core.config import settings logger = logging.getLogger(__name__) class NavServiceUnavailableError(Exception): """ Kivétel, amikor a NAV API szerver nem elérhető hálózati hiba miatt (DNS, timeout, kapcsolódási hiba). Ezt a végpont 503-as hibaként kezeli. """ def __init__(self, message: str = "NAV szerver nem elérhető"): self.message = message super().__init__(self.message) # --- NAV API v3 XML sablonok --- QUERY_TAXPAYER_XML_TEMPLATE = """ {request_id} {timestamp} 3.0 1.0 {user} {password_hash} {caller_tax_number} {request_signature} {software_id} ServiceFinder LOCAL_SOFTWARE 1.0 ServiceFinder dev@servicefinder.hu HU {caller_tax_number} {tax_number} """ class NavService: """ NAV Online Számla API v3 szolgáltatás. Dokumentáció: https://onlineszamla.nav.gov.hu/ """ # Gyakori magyar cégforma rövidítések szótára COMPANY_FORM_SHORT_MAP = { "Korlátolt Felelősségű Társaság": "Kft.", "Betéti Társaság": "Bt.", "Zártkörűen Működő Részvénytársaság": "Zrt.", "Nyilvánosan Működő Részvénytársaság": "Nyrt.", "Közkereseti Társaság": "Kkt.", } @staticmethod def _sha512_hex(value: str) -> str: """SHA-512 hash hex formátumban, felsőoktatással (uppercase).""" return hashlib.sha512(value.encode("utf-8")).hexdigest().upper() @staticmethod def _title_case(text: str) -> str: """ Csupa nagybetűs szöveg átalakítása Title Case formátumra. Példa: 'DUNAKESZI' -> 'Dunakeszi', 'BUDAPEST' -> 'Budapest' Kisbetűs szavakat (pl. 'és', 'a', 'az') meghagyja. """ if not text: return text # Ha nem csupa nagybetűs, akkor nem alakítjuk át if not text.isupper(): return text words = text.split() result_words = [] for word in words: # Ha a szó rövid (1-2 karakter), vagy kötőjeles, kezeljük külön if "-" in word: # Kötőjeles szavak: minden tagot külön kezelünk parts = word.split("-") titled_parts = [p.capitalize() for p in parts] result_words.append("-".join(titled_parts)) else: result_words.append(word.capitalize()) return " ".join(result_words) @staticmethod def _format_company_names(raw_name: str) -> tuple[str, str]: """ Cégnév okos formázása. A NAV-tól kapott csupa nagybetűs névből készít: - full_name: Title Case formátumú teljes név - short_name: Rövidített név (pl. 'Kft.'-re cserélt), töltelékszavak eltávolításával Returns: tuple[str, str]: (full_name, short_name) """ if not raw_name: return "", "" # 1. Title Case formázás full_name = NavService._title_case(raw_name) # 2. Cégforma rövidítésének meghatározása detected_short_form = None short_name_base = full_name for long_form, short_form in NavService.COMPANY_FORM_SHORT_MAP.items(): if long_form in short_name_base: # Eltávolítjuk a hosszú formát a névből a szűrés előtt short_name_base = short_name_base.replace(long_form, "") detected_short_form = short_form break # 3. Töltelékszavak szűrése a rövid névből filler_words = [ "és", "szolgáltató", "kereskedelmi", "üzleti", "tanácsadó", "ipari", "építőipari", "termelő", "fejlesztő", "kivitelező", "általános" ] # Szavakra bontás és töltelékszavak eltávolítása words = short_name_base.split() filtered_words = [w for w in words if w.lower() not in filler_words] # Ha minden szó töltelék volt, tartsuk meg az eredeti nevet if filtered_words: short_name = " ".join(filtered_words).strip() else: short_name = full_name # Ilyenkor újra megkeressük a cégformát a teljes névből for long_form, short_form in NavService.COMPANY_FORM_SHORT_MAP.items(): if long_form in short_name: short_name = short_name.replace(long_form, short_form) break # 4. Cégforma hozzáfűzése a rövid név végére if detected_short_form: short_name = f"{short_name} {detected_short_form}".strip() return full_name, short_name @staticmethod def _sha3_512_hex(value: str) -> str: """SHA3-512 hash hex formátumban, felsőoktatással (uppercase).""" return hashlib.sha3_512(value.encode("utf-8")).hexdigest().upper() @classmethod def _build_header(cls) -> Dict[str, str]: """ NAV API v3 header összeállítása a specifikáció szerint. - passwordHash: NAV_API_PASSWORD felsőoktatású SHA-512 hash-e - requestId: UUID, max 30 karakter - timestamp: UTC időpont YYYY-MM-DDTHH:MM:SS.000Z formátumban - requestSignature (HTTP header): requestId + timestamp + exchangeKey SHA3-512 hash-e (uppercase) - xmlRequestSignature (XML body): requestId + timestamp + signKey SHA3-512 hash-e (uppercase) Fontos: Minden hitelesítő adatot megtisztítunk a whitespace-ektől és idézőjelektől, mert a .env fájlból beolvasott értékek tartalmazhatnak láthatatlan szóközöket, amelyek elrontják a hash-eket (SHA-512 / SHA3-512). """ # Tisztított változók – eltávolítjuk a whitespace-t és az idézőjeleket clean_password = settings.NAV_API_PASSWORD.strip().replace('"', '').replace("'", "") clean_sign_key = settings.NAV_API_SIGN_KEY.strip().replace('"', '').replace("'", "") clean_exchange_key = settings.NAV_API_EXCHANGE_KEY.strip().replace('"', '').replace("'", "") clean_user = settings.NAV_API_USER.strip().replace('"', '').replace("'", "") password_hash = cls._sha512_hex(clean_password) request_id = str(uuid.uuid4()).replace("-", "").upper()[:30] # Kétféle timestamp formátum a NAV API specifikáció szerint: # - timestamp_xml: az XML body-ba kerül, ISO 8601 formátumban (kötőjellel, T-vel, Z-vel) # - timestamp_hash: a hash számításhoz, MASZKOLT formátumban (NINCS kötőjel, NINCS T, NINCS Z) now_utc = datetime.now(timezone.utc) timestamp_xml = now_utc.strftime("%Y-%m-%dT%H:%M:%S.000Z") timestamp_hash = now_utc.strftime("%Y%m%d%H%M%S") # HTTP header RequestSignature = SHA3-512(requestId + timestamp_hash + exchangeKey) http_sig_raw = request_id + timestamp_hash + clean_exchange_key http_request_signature = cls._sha3_512_hex(http_sig_raw) # XML body user.requestSignature = SHA3-512(requestId + timestamp_hash + signKey) xml_sig_raw = request_id + timestamp_hash + clean_sign_key xml_request_signature = cls._sha3_512_hex(xml_sig_raw) # Debug logok a NAV API v3 signature számítás ellenőrzéséhez logger.info(f"NAV DEBUG - Request ID: {request_id}") logger.info(f"NAV DEBUG - Timestamp XML: {timestamp_xml}") logger.info(f"NAV DEBUG - Timestamp Hash: {timestamp_hash}") logger.info(f"NAV DEBUG - Clean Sign Key: {clean_sign_key}") logger.info(f"NAV DEBUG - RAW Sign String: {xml_sig_raw}") logger.info(f"NAV DEBUG - Generated SHA3-512: {xml_request_signature}") return { "requestId": request_id, "timestamp": timestamp_xml, "requestSignature": http_request_signature, "xmlRequestSignature": xml_request_signature, "passwordHash": password_hash, "exchangeKey": clean_exchange_key, "user": clean_user, } @classmethod def _build_query_taxpayer_xml(cls, tax_number: str, header: Dict[str, str]) -> str: """ QueryTaxpayerRequest XML body összeállítása a NAV API v3 specifikáció szerint. Két külön adószámot használ: - caller_tax_number: a hívó fél adószáma (settings.NAV_API_CALLER_TAX_NUMBER-ból), az autentikációhoz a mezőben. - tax_number: a lekérdezni kívánt cég adószáma (a metódus argumentuma), a tényleges lekérdezéshez az outer mezőben. A header paraméter a _build_header() által előállított dict, amely tartalmazza a requestId-t, timestamp-ot és xmlRequestSignature-t. Ezt kívülről kapja meg, hogy a HTTP fejléc és a XML body AZONOS requestId-t és timestamp-ot használjon. Fontos: A HTTP header RequestSignature az exchangeKey-kel, míg a XML body user.requestSignature a signKey-kel számolódik (NAV v3 specifikáció). """ # A hívó (caller) adószámának első 8 számjegye (törzsszám) – autentikációhoz caller_tax_number_core = ( settings.NAV_API_CALLER_TAX_NUMBER .strip() .replace("-", "") .replace(" ", "")[:8] ) # A lekérdezni kívánt (target) adószám első 8 számjegye (törzsszám) – lekérdezéshez target_tax_number_core = ( tax_number .strip() .replace("-", "") .replace(" ", "")[:8] ) # A user.requestSignature = SHA3-512(requestId + timestamp + signKey) # a cryptoType="SHA3-512" attribútummal # EZT a header dict-ben xmlRequestSignature-ként tároljuk user_request_signature = header["xmlRequestSignature"] xml_body = QUERY_TAXPAYER_XML_TEMPLATE.format( request_id=header["requestId"], timestamp=header["timestamp"], caller_tax_number=caller_tax_number_core, tax_number=target_tax_number_core, user=header["user"], password_hash=header["passwordHash"], request_signature=user_request_signature, software_id="SF-NAV-API-001-000", ) return xml_body @classmethod def _parse_taxpayer_response(cls, xml_text: str) -> Optional[Dict[str, Any]]: """ NAV API válasz feldolgozása – névtér-független parser. A NAV a funcCode-ot a common, az adatokat az api névtérben küldi, ezért a root.iter()-en végigiterálva, a tag név névtérrészének levágásával (el.tag.split("}")[-1]) keressük az elemeket. """ try: import xml.etree.ElementTree as ET root = ET.fromstring(xml_text) def get_text(tag_name: str) -> Optional[str]: """ Végigiterál a root.iter()-en, és visszaadja az első olyan elem szöveges tartalmát, amelynek a neve (névtér prefix levágása után) megegyezik a tag_name paraméterrel. """ for el in root.iter(): local_tag = el.tag.split("}")[-1] if local_tag == tag_name: return el.text if el.text else None return None # funcCode ellenőrzés – a NAV a common névtérben küldi func_code = get_text("funcCode") if func_code is None: logger.warning("NAV válasz: funcCode nem található") return None if func_code != "OK": # Hibaüzenet kiolvasása – a NAV a message tag-et a common névtérben küldi error_msg = get_text("message") or "Ismeretlen hiba" logger.error(f"NAV válasz hibakód: {func_code} – {error_msg}") return None # Adózói adatok kinyerése – névtérfüggetlenül taxpayer_name = get_text("taxpayerName") or "" city = get_text("city") or "" postal_code = get_text("postalCode") or "" street_name = get_text("streetName") or "" public_place_category = get_text("publicPlaceCategory") or "" number = get_text("number") or "" # --- Cégnév okos formázása --- full_name, short_name = cls._format_company_names(taxpayer_name) # --- Címek Title Case formázása --- formatted_city = cls._title_case(city) formatted_street_name = cls._title_case(street_name) formatted_public_place = cls._title_case(public_place_category) # Utcanév + közterület jellege összefűzése full_street = formatted_street_name if formatted_public_place: full_street = f"{formatted_street_name} {formatted_public_place}" result = { "full_name": full_name, "name": short_name, "display_name": short_name, "address_zip": postal_code, "address_city": formatted_city, "address_street_name": full_street.strip(), "address_street_type": formatted_public_place, "address_house_number": number, } logger.info(f"NAV API sikeres lekérdezés: {full_name}, {formatted_city}") return result except ET.ParseError as e: logger.error(f"NAV válasz XML parse hiba: {e}") return None except Exception as e: logger.error(f"NAV válasz feldolgozási hiba: {e}", exc_info=True) return None @classmethod async def query_taxpayer(cls, tax_number: str) -> Optional[Dict[str, Any]]: """ Adószám alapú cégadat lekérdezés a NAV Online Számla API v3 queryTaxpayer végpontján keresztül. Args: tax_number: Magyar adószám (XXXXXXXX-Y-ZZ vagy XXXXXXXX formátumban) Returns: Dict a cég adataival (full_name, name, display_name, address_zip, address_city, address_street_name, address_street_type, address_house_number) vagy None, ha a NAV nem találja az adószámot. Raises: NavServiceUnavailableError: Ha hálózati hiba (DNS, timeout, kapcsolódási) történik. """ # EGYSZER hívjuk a _build_header()-t, hogy a requestId, timestamp és # requestSignature AZONOS legyen mind a XML body-ban, mind a HTTP fejlécben header = cls._build_header() xml_body = cls._build_query_taxpayer_xml(tax_number, header) # NAV API fejléc (HTTP header) – csak a szükséges fejlécek http_headers = { "Content-Type": "application/xml; charset=UTF-8", "Accept": "application/xml; charset=UTF-8", } url = f"{settings.NAV_API_BASE_URL}/queryTaxpayer" logger.info(f"NAV API hívás: queryTaxpayer, adószám törzs: {tax_number[:8]}...") try: async with httpx.AsyncClient(timeout=30.0, verify=False) as client: response = await client.post(url, content=xml_body, headers=http_headers) if response.status_code != 200: # Próbáljuk kinyerni a NAV által küldött hibaüzenetet a válaszból error_detail = cls._parse_taxpayer_response(response.text) if error_detail: logger.error( f"NAV API HTTP hiba: {response.status_code} – {error_detail}" ) else: # Ha a parser nem tudta kiolvasni, logoljuk a teljes választ logger.error( f"NAV API HTTP hiba: {response.status_code} – Teljes válasz: {response.text}" ) return None result = cls._parse_taxpayer_response(response.text) return result except httpx.TimeoutException: logger.error("NAV API timeout – a szerver nem válaszolt 30 másodpercen belül") raise NavServiceUnavailableError("NAV API timeout – a szerver nem válaszolt 30 másodpercen belül") except httpx.RequestError as e: logger.error(f"NAV API kapcsolódási hiba: {e}") raise NavServiceUnavailableError(f"NAV szerver nem elérhető: {e}") except Exception as e: logger.error(f"NAV API váratlan hiba: {e}", exc_info=True) return None