nordabiz/blueprints/api/routes_membership.py
Maciej Pienczyn c73e90bc70 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>
2026-02-01 14:32:36 +01:00

599 lines
21 KiB
Python

"""
Membership API Routes
======================
API endpoints for membership application system:
- NIP lookup in KRS/CEIDG registries
- Draft save/load
- Application submission
- Company data requests
"""
import logging
from datetime import datetime
from flask import jsonify, request
from flask_login import current_user, login_required
from database import (
SessionLocal, MembershipApplication, CompanyDataRequest, Company
)
from . import bp
logger = logging.getLogger(__name__)
# ============================================================
# NIP LOOKUP (KRS/CEIDG)
# ============================================================
@bp.route('/membership/lookup-nip', methods=['POST'])
@login_required
def lookup_nip():
"""
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()
if not data:
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
if not nip.isdigit():
return jsonify({'success': False, 'error': 'NIP może zawierać tylko cyfry'}), 400
# 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({
'success': True,
'source': 'KRS',
'data': krs_result
})
# Option 3: Try CEIDG (for JDG - sole proprietorship)
ceidg_result = _lookup_ceidg(nip)
if ceidg_result:
return jsonify({
'success': True,
'source': 'CEIDG',
'data': ceidg_result
})
# Not found in any registry
return jsonify({
'success': True,
'source': 'manual',
'data': None,
'message': 'Firma nie została znaleziona w KRS ani CEIDG. Wypełnij dane ręcznie.'
})
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 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:
return _parse_krs_data(result)
except ImportError:
logger.warning("KRS API service not available")
except Exception as e:
logger.error(f"KRS lookup error for NIP {nip}: {e}")
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:
from ceidg_api_service import fetch_ceidg_by_nip
result = fetch_ceidg_by_nip(nip)
if result:
address = result.get('adresDzialalnosci', {})
return {
'name': result.get('firma'),
'regon': result.get('regon'),
'address_postal_code': address.get('kodPocztowy', ''),
'address_city': address.get('miejscowosc', ''),
'address_street': address.get('ulica', ''),
'address_number': address.get('budynek', ''),
'founded_date': result.get('dataRozpoczeciaDzialalnosci'),
'business_type': 'jdg',
'email': result.get('email'),
'website': result.get('stronaWWW'),
'raw': result
}
except ImportError:
logger.warning("CEIDG API service not available")
except Exception as e:
logger.error(f"CEIDG lookup error for NIP {nip}: {e}")
return None
def _detect_business_type_from_krs(data):
"""Detect business type from KRS data."""
legal_form = data.get('forma_prawna', '').lower()
if 'akcyjna' in legal_form and 'komandytowo' in legal_form:
return 'spolka_komandytowo_akcyjna'
elif 'akcyjna' in legal_form:
return 'spolka_akcyjna'
elif 'z ograniczoną odpowiedzialnością' in legal_form or 'z o.o.' in legal_form:
if 'komandytowa' in legal_form:
return 'sp_z_oo_komandytowa'
return 'sp_z_oo'
elif 'komandytowa' in legal_form:
return 'spolka_komandytowa'
elif 'partnerska' in legal_form:
return 'spolka_partnerska'
elif 'jawna' in legal_form:
return 'spolka_jawna'
elif 'cywilna' in legal_form:
return 'spolka_cywilna'
return 'inna'
# ============================================================
# MEMBERSHIP APPLICATION DRAFT
# ============================================================
@bp.route('/membership/draft', methods=['GET'])
@login_required
def get_draft():
"""Get current draft application."""
db = SessionLocal()
try:
application = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['draft', 'changes_requested'])
).first()
if not application:
return jsonify({'success': True, 'draft': None})
return jsonify({
'success': True,
'draft': _serialize_application(application)
})
finally:
db.close()
@bp.route('/membership/draft', methods=['POST'])
@login_required
def save_draft():
"""Save draft application data."""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Brak danych'}), 400
db = SessionLocal()
try:
# Get or create draft
application = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['draft', 'changes_requested'])
).first()
if not application:
application = MembershipApplication(
user_id=current_user.id,
company_name=data.get('company_name', ''),
nip=data.get('nip', ''),
email=data.get('email', current_user.email or ''),
status='draft'
)
db.add(application)
# Update fields
_update_application_from_data(application, data)
application.updated_at = datetime.now()
db.commit()
return jsonify({
'success': True,
'message': 'Zapisano',
'application_id': application.id
})
except Exception as e:
db.rollback()
logger.error(f"Error saving draft: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
def _update_application_from_data(app, data):
"""Update application fields from request data."""
# Step 1 fields
if 'company_name' in data:
app.company_name = data['company_name'].strip()
if 'nip' in data:
app.nip = data['nip'].strip().replace('-', '').replace(' ', '')
if 'address_postal_code' in data:
app.address_postal_code = data['address_postal_code'].strip()
if 'address_city' in data:
app.address_city = data['address_city'].strip()
if 'address_street' in data:
app.address_street = data['address_street'].strip()
if 'address_number' in data:
app.address_number = data['address_number'].strip()
if 'delegate_1' in data:
app.delegate_1 = data['delegate_1'].strip()
if 'delegate_2' in data:
app.delegate_2 = data['delegate_2'].strip()
if 'delegate_3' in data:
app.delegate_3 = data['delegate_3'].strip()
if 'krs_number' in data:
app.krs_number = data['krs_number'].strip()
if 'regon' in data:
app.regon = data['regon'].strip()
if 'registry_source' in data:
app.registry_source = data['registry_source']
if 'registry_data' in data:
app.registry_data = data['registry_data']
# Step 2 fields
if 'website' in data:
app.website = data['website'].strip()
if 'email' in data:
app.email = data['email'].strip()
if 'phone' in data:
app.phone = data['phone'].strip()
if 'short_name' in data:
app.short_name = data['short_name'].strip()
if 'description' in data:
app.description = data['description'].strip()
if 'founded_date' in data and data['founded_date']:
try:
app.founded_date = datetime.strptime(data['founded_date'], '%Y-%m-%d').date()
except ValueError:
pass
if 'employee_count' in data and data['employee_count']:
try:
app.employee_count = int(data['employee_count'])
except ValueError:
pass
if 'show_employee_count' in data:
app.show_employee_count = bool(data['show_employee_count'])
if 'annual_revenue' in data:
app.annual_revenue = data['annual_revenue'].strip() if data['annual_revenue'] else None
if 'related_companies' in data:
app.related_companies = data['related_companies'] if data['related_companies'] else None
# Step 3 fields
if 'sections' in data:
app.sections = data['sections'] if data['sections'] else []
if 'sections_other' in data:
app.sections_other = data['sections_other'].strip() if data['sections_other'] else None
if 'business_type' in data:
app.business_type = data['business_type']
if 'business_type_other' in data:
app.business_type_other = data['business_type_other'].strip() if data['business_type_other'] else None
if 'consent_email' in data:
app.consent_email = bool(data['consent_email'])
if 'consent_email_address' in data:
app.consent_email_address = data['consent_email_address'].strip() if data['consent_email_address'] else None
if 'consent_sms' in data:
app.consent_sms = bool(data['consent_sms'])
if 'consent_sms_phone' in data:
app.consent_sms_phone = data['consent_sms_phone'].strip() if data['consent_sms_phone'] else None
def _serialize_application(app):
"""Serialize application to dict."""
return {
'id': app.id,
'status': app.status,
'company_name': app.company_name,
'nip': app.nip,
'address_postal_code': app.address_postal_code,
'address_city': app.address_city,
'address_street': app.address_street,
'address_number': app.address_number,
'delegate_1': app.delegate_1,
'delegate_2': app.delegate_2,
'delegate_3': app.delegate_3,
'krs_number': app.krs_number,
'regon': app.regon,
'registry_source': app.registry_source,
'website': app.website,
'email': app.email,
'phone': app.phone,
'short_name': app.short_name,
'description': app.description,
'founded_date': app.founded_date.isoformat() if app.founded_date else None,
'employee_count': app.employee_count,
'show_employee_count': app.show_employee_count,
'annual_revenue': app.annual_revenue,
'related_companies': app.related_companies,
'sections': app.sections,
'sections_other': app.sections_other,
'business_type': app.business_type,
'business_type_other': app.business_type_other,
'consent_email': app.consent_email,
'consent_email_address': app.consent_email_address,
'consent_sms': app.consent_sms,
'consent_sms_phone': app.consent_sms_phone,
'declaration_accepted': app.declaration_accepted,
'created_at': app.created_at.isoformat() if app.created_at else None,
'updated_at': app.updated_at.isoformat() if app.updated_at else None,
'submitted_at': app.submitted_at.isoformat() if app.submitted_at else None
}
# ============================================================
# MEMBERSHIP APPLICATION SUBMIT
# ============================================================
@bp.route('/membership/submit', methods=['POST'])
@login_required
def submit_application():
"""Submit draft application for review."""
db = SessionLocal()
try:
application = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id,
MembershipApplication.status.in_(['draft', 'changes_requested'])
).first()
if not application:
return jsonify({'success': False, 'error': 'Nie znaleziono deklaracji'}), 404
# Validate
errors = _validate_application(application)
if errors:
return jsonify({'success': False, 'errors': errors}), 400
# Submit
application.status = 'submitted'
application.submitted_at = datetime.now()
application.declaration_accepted_at = datetime.now()
application.declaration_ip_address = request.remote_addr
db.commit()
logger.info(f"Membership application submitted: user={current_user.id}, app={application.id}")
return jsonify({
'success': True,
'message': 'Deklaracja została wysłana do rozpatrzenia',
'application_id': application.id
})
except Exception as e:
db.rollback()
logger.error(f"Error submitting application: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
def _validate_application(app):
"""Validate application before submission."""
errors = []
if not app.company_name:
errors.append('Nazwa firmy jest wymagana')
if not app.nip or len(app.nip) != 10:
errors.append('NIP jest wymagany (10 cyfr)')
if not app.email:
errors.append('Email jest wymagany')
if not app.delegate_1:
errors.append('Przynajmniej jeden delegat jest wymagany')
if not app.sections:
errors.append('Wybierz przynajmniej jedną sekcję tematyczną')
if not app.consent_email:
errors.append('Zgoda na kontakt email jest wymagana')
if not app.declaration_accepted:
errors.append('Musisz zaakceptować oświadczenie')
return errors
# ============================================================
# MEMBERSHIP STATUS
# ============================================================
@bp.route('/membership/status', methods=['GET'])
@login_required
def get_status():
"""Get application status for current user."""
db = SessionLocal()
try:
applications = db.query(MembershipApplication).filter(
MembershipApplication.user_id == current_user.id
).order_by(MembershipApplication.created_at.desc()).all()
return jsonify({
'success': True,
'applications': [
{
'id': app.id,
'status': app.status,
'status_label': app.status_label,
'company_name': app.company_name,
'created_at': app.created_at.isoformat() if app.created_at else None,
'submitted_at': app.submitted_at.isoformat() if app.submitted_at else None,
'reviewed_at': app.reviewed_at.isoformat() if app.reviewed_at else None,
'review_comment': app.review_comment
}
for app in applications
]
})
finally:
db.close()
# ============================================================
# COMPANY DATA REQUEST
# ============================================================
@bp.route('/company/data-request', methods=['POST'])
@login_required
def create_data_request():
"""Create a company data update request."""
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'Brak danych'}), 400
if not current_user.company_id:
return jsonify({'success': False, 'error': 'Nie masz przypisanej firmy'}), 400
nip = data.get('nip', '').strip().replace('-', '').replace(' ', '')
if not nip or len(nip) != 10:
return jsonify({'success': False, 'error': 'NIP musi mieć 10 cyfr'}), 400
db = SessionLocal()
try:
# Check for existing pending request
existing = db.query(CompanyDataRequest).filter(
CompanyDataRequest.user_id == current_user.id,
CompanyDataRequest.company_id == current_user.company_id,
CompanyDataRequest.status == 'pending'
).first()
if existing:
return jsonify({'success': False, 'error': 'Masz już oczekujące zgłoszenie'}), 400
# Fetch registry data
registry_data = None
registry_source = None
krs_result = _lookup_krs(nip)
if krs_result:
registry_data = krs_result
registry_source = 'KRS'
else:
ceidg_result = _lookup_ceidg(nip)
if ceidg_result:
registry_data = ceidg_result
registry_source = 'CEIDG'
# Create request
data_request = CompanyDataRequest(
request_type=data.get('request_type', 'update_data'),
user_id=current_user.id,
company_id=current_user.company_id,
nip=nip,
registry_source=registry_source,
fetched_data=registry_data,
user_note=data.get('user_note', '').strip() if data.get('user_note') else None
)
db.add(data_request)
db.commit()
logger.info(f"Company data request created: user={current_user.id}, company={current_user.company_id}")
return jsonify({
'success': True,
'message': 'Zgłoszenie zostało wysłane do rozpatrzenia',
'request_id': data_request.id,
'registry_data': registry_data
})
except Exception as e:
db.rollback()
logger.error(f"Error creating data request: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/company/data-request/status', methods=['GET'])
@login_required
def get_data_request_status():
"""Get company data request status."""
if not current_user.company_id:
return jsonify({'success': True, 'requests': []})
db = SessionLocal()
try:
requests = db.query(CompanyDataRequest).filter(
CompanyDataRequest.user_id == current_user.id
).order_by(CompanyDataRequest.created_at.desc()).all()
return jsonify({
'success': True,
'requests': [
{
'id': req.id,
'request_type': req.request_type,
'request_type_label': req.request_type_label,
'status': req.status,
'status_label': req.status_label,
'nip': req.nip,
'registry_source': req.registry_source,
'created_at': req.created_at.isoformat() if req.created_at else None,
'reviewed_at': req.reviewed_at.isoformat() if req.reviewed_at else None,
'review_comment': req.review_comment
}
for req in requests
]
})
finally:
db.close()