feat: Add Biała Lista VAT integration for NIP→KRS lookup

- Use official Ministry of Finance API (wl-api.mf.gov.pl) to get KRS from NIP
- Add KRS field to membership application form
- Workflow: NIP → Biała Lista → KRS Open API → full company data
- Fallback to CEIDG for JDG (sole proprietorship)
- Remove rejestr.io dependency - only official government APIs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-01 14:32:36 +01:00
parent 28affce99f
commit c73e90bc70
3 changed files with 134 additions and 69 deletions

View File

@ -31,7 +31,13 @@ logger = logging.getLogger(__name__)
@login_required
def lookup_nip():
"""
Lookup company data by NIP in KRS and CEIDG registries.
Lookup company data by NIP (and optionally KRS) in official registries.
Workflow:
1. If KRS provided - directly query KRS Open API
2. If only NIP - query Biała Lista VAT to get KRS, then KRS Open API
3. If no KRS found - try CEIDG (for JDG/sole proprietorship)
Returns company info for auto-fill in application form.
"""
data = request.get_json()
@ -39,14 +45,25 @@ def lookup_nip():
return jsonify({'success': False, 'error': 'Brak danych'}), 400
nip = data.get('nip', '').strip().replace('-', '').replace(' ', '')
krs = data.get('krs', '').strip().replace('-', '').replace(' ', '') if data.get('krs') else None
if not nip or len(nip) != 10:
return jsonify({'success': False, 'error': 'NIP musi mieć 10 cyfr'}), 400
# Check if NIP is numeric
if not nip.isdigit():
return jsonify({'success': False, 'error': 'NIP może zawierać tylko cyfry'}), 400
# Try KRS first
# Option 1: If KRS provided, use it directly
if krs and len(krs) >= 7 and krs.isdigit():
krs_result = _lookup_krs_by_number(krs)
if krs_result:
return jsonify({
'success': True,
'source': 'KRS',
'data': krs_result
})
# Option 2: Try KRS via NIP (uses Biała Lista VAT → KRS Open API)
krs_result = _lookup_krs(nip)
if krs_result:
return jsonify({
@ -55,7 +72,7 @@ def lookup_nip():
'data': krs_result
})
# Try CEIDG
# Option 3: Try CEIDG (for JDG - sole proprietorship)
ceidg_result = _lookup_ceidg(nip)
if ceidg_result:
return jsonify({
@ -73,31 +90,28 @@ def lookup_nip():
})
def _lookup_krs_by_number(krs_number):
"""Lookup in KRS registry directly by KRS number."""
try:
from krs_api_service import get_company_from_krs
krs_normalized = krs_number.zfill(10)
result = get_company_from_krs(krs_normalized)
if result:
return _parse_krs_data(result.to_dict())
except ImportError:
logger.warning("KRS API service not available")
except Exception as e:
logger.error(f"KRS lookup error for KRS {krs_number}: {e}")
return None
def _lookup_krs(nip):
"""Lookup in KRS registry."""
"""Lookup in KRS registry by NIP (via Biała Lista VAT → KRS Open API)."""
try:
from krs_api_service import krs_api_service
result = krs_api_service.search_by_nip(nip)
if result:
# Parse address components
address = result.get('adres', {})
if isinstance(address, str):
address = {'full': address}
return {
'name': result.get('nazwa'),
'krs': result.get('krs'),
'regon': result.get('regon'),
'address_postal_code': address.get('kodPocztowy', ''),
'address_city': address.get('miejscowosc', ''),
'address_street': address.get('ulica', ''),
'address_number': address.get('nrDomu', ''),
'founded_date': result.get('data_rejestracji'),
'business_type': _detect_business_type_from_krs(result),
'email': result.get('email'),
'website': result.get('strona_www'),
'raw': result
}
return _parse_krs_data(result)
except ImportError:
logger.warning("KRS API service not available")
except Exception as e:
@ -105,6 +119,32 @@ def _lookup_krs(nip):
return None
def _parse_krs_data(result):
"""Parse KRS data into standardized format."""
# Parse address components
address = result.get('adres', {})
if isinstance(address, str):
address = {'full': address}
# Handle kontakt_krs for email/website
kontakt = result.get('kontakt_krs', {}) or {}
return {
'name': result.get('nazwa'),
'krs': result.get('krs'),
'regon': result.get('regon'),
'address_postal_code': address.get('kod_pocztowy') or address.get('kodPocztowy', ''),
'address_city': address.get('miejscowosc', ''),
'address_street': address.get('ulica', ''),
'address_number': address.get('nr_domu') or address.get('nrDomu', ''),
'founded_date': result.get('daty', {}).get('rejestracji') if isinstance(result.get('daty'), dict) else result.get('data_rejestracji'),
'business_type': _detect_business_type_from_krs(result),
'email': kontakt.get('email') or result.get('email'),
'website': kontakt.get('www') or result.get('strona_www'),
'raw': result
}
def _lookup_ceidg(nip):
"""Lookup in CEIDG registry."""
try:

View File

@ -444,25 +444,27 @@ def format_address(krs_data: KRSCompanyData) -> str:
# KRS API Service Class (for search_by_nip compatibility)
# ============================================================
# Biała Lista VAT API (Ministry of Finance) - returns KRS from NIP
BIALA_LISTA_API_URL = "https://wl-api.mf.gov.pl/api/search/nip"
BIALA_LISTA_TIMEOUT = 10
class KRSApiService:
"""
KRS API Service class providing unified interface for KRS lookups.
Note: KRS Open API doesn't support direct NIP lookup.
This class uses rejestr.io unofficial API as a fallback.
Uses official government APIs:
1. Biała Lista VAT (wl-api.mf.gov.pl) - to get KRS from NIP
2. KRS Open API (api-krs.ms.gov.pl) - to get full company data from KRS
"""
REJESTR_IO_URL = "https://rejestr.io/api/v2/org"
REJESTR_IO_TIMEOUT = 10
def search_by_nip(self, nip: str) -> Optional[Dict[str, Any]]:
"""
Search KRS by NIP number.
Search KRS by NIP number using official government APIs.
Since KRS Open API doesn't support NIP lookup, this method:
1. First tries rejestr.io API (unofficial but reliable)
2. Falls back to checking our database for KRS number
3. Then fetches full data from KRS Open API
Workflow:
1. Query Biała Lista VAT API (Ministry of Finance) to get KRS from NIP
2. Fetch full data from KRS Open API using the KRS number
Args:
nip: NIP number (10 digits)
@ -471,6 +473,7 @@ class KRSApiService:
Dictionary with company data or None if not found
"""
import logging
from datetime import date
logger = logging.getLogger(__name__)
# Clean NIP
@ -478,19 +481,19 @@ class KRSApiService:
if not nip or len(nip) != 10 or not nip.isdigit():
return None
# Try rejestr.io first (supports NIP lookup)
krs_number = self._get_krs_from_rejestr_io(nip)
# Step 1: Get KRS from Biała Lista VAT API
krs_number = self._get_krs_from_biala_lista(nip)
if not krs_number:
# Try our database
# Fallback: check our database
krs_number = self._get_krs_from_database(nip)
if not krs_number:
logger.info(f"No KRS found for NIP {nip}")
return None
# Fetch full data from KRS Open API
logger.info(f"Found KRS {krs_number} for NIP {nip}, fetching details")
# Step 2: Fetch full data from official KRS Open API
logger.info(f"Found KRS {krs_number} for NIP {nip}, fetching from KRS Open API")
krs_data = get_company_from_krs(krs_number)
if not krs_data:
@ -499,39 +502,43 @@ class KRSApiService:
# Return as dict with expected format
return krs_data.to_dict()
def _get_krs_from_rejestr_io(self, nip: str) -> Optional[str]:
"""Try to get KRS number from rejestr.io API."""
def _get_krs_from_biala_lista(self, nip: str) -> Optional[str]:
"""
Get KRS number from Biała Lista VAT API (Ministry of Finance).
Official API: https://wl-api.mf.gov.pl/
"""
import logging
from datetime import date
logger = logging.getLogger(__name__)
try:
# rejestr.io API endpoint for NIP lookup
url = f"{self.REJESTR_IO_URL}"
params = {"nip": nip}
today = date.today().strftime('%Y-%m-%d')
url = f"{BIALA_LISTA_API_URL}/{nip}?date={today}"
response = requests.get(url, params=params, timeout=self.REJESTR_IO_TIMEOUT)
response = requests.get(url, timeout=BIALA_LISTA_TIMEOUT)
if response.status_code == 200:
data = response.json()
# Handle different response formats
if isinstance(data, list) and data:
krs = data[0].get('krs')
if krs:
return str(krs).zfill(10)
elif isinstance(data, dict):
krs = data.get('krs')
if krs:
return str(krs).zfill(10)
subject = data.get('result', {}).get('subject', {})
if subject:
krs = subject.get('krs')
if krs:
logger.info(f"Biała Lista: Found KRS {krs} for NIP {nip}")
return str(krs).zfill(10)
else:
logger.debug(f"Biała Lista: NIP {nip} found but no KRS (likely JDG)")
else:
logger.debug(f"Biała Lista: NIP {nip} not found")
logger.debug(f"rejestr.io: No KRS found for NIP {nip}")
return None
except Exception as e:
logger.debug(f"rejestr.io lookup failed: {e}")
logger.warning(f"Biała Lista API error for NIP {nip}: {e}")
return None
def _get_krs_from_database(self, nip: str) -> Optional[str]:
"""Check our database for KRS number."""
"""Check our database for KRS number (fallback)."""
import logging
logger = logging.getLogger(__name__)

View File

@ -129,6 +129,12 @@
margin-top: var(--spacing-xs);
}
.form-hint-inline {
font-size: var(--font-size-sm);
color: var(--text-secondary);
font-weight: 400;
}
.nip-lookup {
display: flex;
gap: var(--spacing-sm);
@ -367,17 +373,28 @@
<div class="form-section">
<h2>Pobierz dane z rejestru</h2>
<div class="form-group">
<label>NIP <span class="required">*</span></label>
<div class="nip-lookup">
<div class="form-row">
<div class="form-group">
<label>NIP <span class="required">*</span></label>
<input type="text" class="form-control" id="nipInput" name="nip"
value="{{ application.nip or '' }}"
placeholder="0000000000" maxlength="10" pattern="\d{10}">
<button type="button" class="btn-lookup" id="btnLookup">
Sprawdź w rejestrze
</button>
<div class="form-hint">10 cyfr, bez myślników</div>
</div>
<div class="form-hint">Wpisz 10-cyfrowy NIP bez myślników</div>
<div class="form-group">
<label>KRS <span class="form-hint-inline">(dla spółek)</span></label>
<input type="text" class="form-control" id="krsInput" name="krs_number"
value="{{ application.krs_number or '' }}"
placeholder="0000000000" maxlength="10" pattern="\d{7,10}">
<div class="form-hint">7-10 cyfr (opcjonalne dla JDG)</div>
</div>
</div>
<div class="form-group">
<button type="button" class="btn-lookup" id="btnLookup" style="width: 100%;">
Sprawdź w rejestrze (KRS lub CEIDG)
</button>
<div class="form-hint">Podaj NIP. Dla spółek (Sp. z o.o., SA) podaj też KRS aby pobrać dane z rejestru.</div>
</div>
<div id="registryPreview" class="registry-preview" style="display: none;">
@ -428,7 +445,6 @@
</div>
</div>
<input type="hidden" name="krs_number" value="{{ application.krs_number or '' }}">
<input type="hidden" name="regon" value="{{ application.regon or '' }}">
<input type="hidden" name="registry_source" value="{{ application.registry_source or 'manual' }}">
</div>
@ -687,6 +703,7 @@
{% block extra_js %}
{% if step == 1 %}
const nipInput = document.getElementById('nipInput');
const krsInput = document.getElementById('krsInput');
const btnLookup = document.getElementById('btnLookup');
const registryPreview = document.getElementById('registryPreview');
const registrySource = document.getElementById('registrySource');
@ -694,6 +711,7 @@ const registryData = document.getElementById('registryData');
btnLookup.addEventListener('click', async function() {
const nip = nipInput.value.replace(/[\s-]/g, '');
const krs = krsInput ? krsInput.value.replace(/[\s-]/g, '') : '';
if (nip.length !== 10 || !/^\d+$/.test(nip)) {
alert('NIP musi mieć 10 cyfr');
@ -701,13 +719,13 @@ btnLookup.addEventListener('click', async function() {
}
btnLookup.disabled = true;
btnLookup.innerHTML = '<span class="loading-spinner"></span> Sprawdzam...';
btnLookup.innerHTML = '<span class="loading-spinner"></span> Sprawdzam w rejestrach...';
try {
const response = await fetch('/api/membership/lookup-nip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nip: nip })
body: JSON.stringify({ nip: nip, krs: krs || null })
});
const result = await response.json();
@ -742,10 +760,10 @@ btnLookup.addEventListener('click', async function() {
}
} catch (error) {
console.error('Lookup error:', error);
alert('Błąd podczas sprawdzania NIP');
alert('Błąd podczas sprawdzania w rejestrach');
} finally {
btnLookup.disabled = false;
btnLookup.innerHTML = 'Sprawdź w rejestrze';
btnLookup.innerHTML = 'Sprawdź w rejestrze (KRS lub CEIDG)';
}
});
{% endif %}