# /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