commit 35571df77801b5ace720612eabe0cf99cacd8365 Author: Mateusz Gruszczyński Date: Fri Mar 13 11:03:13 2026 +0100 push diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..113fe8d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +.venv/ +venv +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +instance/* +*.db + +.env +.env.* + +dist/ +build/ +*.egg-info/ +.pytest_cache + +*.pdf +*.xml +storage/* +backups/* +certs/* +pdf/* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3f004c9 --- /dev/null +++ b/.env.example @@ -0,0 +1,90 @@ +# ============================================ +# KSeF Manager – przykładowy plik .env.example +# ============================================ + +# ================================ +# Klucze bezpieczeństwa aplikacji +# ================================ + +# Sekretny klucz Flask używany do: +# - podpisywania sesji +# - tokenów CSRF +# - zabezpieczeń aplikacji +SECRET_KEY=change-me-please + +# Opcjonalny klucz nadrzędny aplikacji. +# Jeśli nie ustawiony, przyjmuje wartość SECRET_KEY. +# Używany do szyfrowania wrażliwych danych (np. certyfikatów). +APP_MASTER_KEY= + + +# ================================ +# Konfiguracja domeny / reverse proxy +# ================================ + +# Domeną pod którą dostępna jest aplikacja +# (bez https:// i bez portu) +APP_DOMAIN=ksef.local + +# Schemat zewnętrzny +APP_EXTERNAL_SCHEME=https + +# Port wystawiony na zewnątrz przez Caddy / Docker +EXPOSE_PORT=8785 + + +# ================================ +# Baza danych +# ================================ + +# Adres SQLAlchemy +# +# Przykłady: +# sqlite:///instance/app.db +# postgresql://user:pass@localhost/dbname +DATABASE_URL=sqlite:///instance/app.db + + +# ================================ +# Redis / Cache / Rate limit +# ================================ + +# Redis używany do: +# - cache dashboardu +# - rate-limit +# - kolejek +# +# Jeśli puste → fallback do pamięci aplikacji +REDIS_URL=redis://redis:6379/0 + + +# ================================ +# Ścieżki robocze aplikacji +# ================================ + +# Jeśli nie ustawione, aplikacja utworzy katalogi w storage/* +ARCHIVE_PATH=storage/archive +PDF_PATH=storage/pdf +BACKUP_PATH=storage/backups +CERTS_PATH=storage/certs + + +# ================================ +# Konfiguracja aplikacji +# ================================ + +# Strefa czasowa aplikacji +APP_TIMEZONE=Europe/Warsaw + +# Poziom logowania +# DEBUG / INFO / WARNING / ERROR / CRITICAL +LOG_LEVEL=INFO + + +# ================================ +# Port aplikacji (wewnętrzny) +# ================================ + +# Port na którym Flask/Gunicorn działa w kontenerze +# Nie zmieniać przy Docker Compose +APP_PORT=5000 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31d1df0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +.venv/ +venv +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +instance/* +*.db + +.env + +dist/ +build/ +*.egg-info/ +.pytest_cache + +*.pdf +*.xml +storage/* +backups/* +certs/* +pdf/* diff --git a/DEPLOY_SSL_NOTES.txt b/DEPLOY_SSL_NOTES.txt new file mode 100644 index 0000000..74f4dcb --- /dev/null +++ b/DEPLOY_SSL_NOTES.txt @@ -0,0 +1,20 @@ +Zmiany wdrożeniowe +==================== + +1. Docker uruchamia aplikację przez Gunicorn: + gunicorn -w 1 -k gthread --threads 8 -b 0.0.0.0:5000 run:app + +2. Dodano Caddy jako reverse proxy HTTPS. + - konfiguracja: deploy/caddy/Caddyfile + - certyfikaty TLS: deploy/caddy/ssl/server.crt oraz deploy/caddy/ssl/server.key + +3. Dodano skrypt wdrożeniowy: deploy_docer.sh + Skrypt: + - generuje self-signed cert, gdy brak plików SSL, + - wykonuje docker compose pull, + - buduje bez cache, + - zatrzymuje stary stack, + - czyści stare obrazy/builder cache, + - uruchamia stack ponownie. + +4. Aplikacja ufa nagłówkom reverse proxy (ProxyFix) i ma secure cookies dla HTTPS. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2068d49 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.14-alpine + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apk add --no-cache \ + gcc musl-dev python3-dev \ + \ + libffi-dev \ + jpeg-dev \ + zlib-dev \ + \ + cairo-dev \ + pango-dev \ + gdk-pixbuf-dev \ + glib-dev \ + freetype-dev \ + fontconfig-dev \ + \ + pkgconfig + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . +RUN mkdir -p instance storage/archive storage/pdf storage/backups + +CMD ["gunicorn", "-w", "1", "-k", "gthread", "--threads", "8", "-b", "0.0.0.0:5000", "run:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fc1ae2 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# STATUS APLIKACJI WIP ! + +# KSeF Flask App + + +## Start lokalny + +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +mkdir -p instance storage/archive storage/pdf storage/backups storage/certs +flask --app run.py init-db +flask --app run.py create-company +flask --app run.py create-user +python run.py +``` + +## CLI w Docker + +```bash +docker compose run --rm web flask --app run.py flask --app run.py init-db # nie wymagane, baza się inicjuje automatycznie +docker compose run --rm web flask --app run.py flask --app run.py create-company # opcjonalne +docker compose run --rm web flask --app run.py flask --app run.py create-user # utworzenie pierwszego admina +``` + +## Jak działa `.env` + +W `.env` trzymane są tylko ustawienia techniczne aplikacji. Dane biznesowe, takie jak KSeF, SMTP, Pushover, tokeny i certyfikaty, ustawia się z panelu WWW osobno dla każdej firmy. + +### Pola w `.env.example` + +- `SECRET_KEY` — klucz Flask do sesji i CSRF. +- `APP_MASTER_KEY` — klucz do szyfrowania danych w bazie, np. tokenów i certyfikatów. +- `DATABASE_URL` — połączenie do bazy. Dla SQLite może zostać `sqlite:///instance/app.db`. +- `APP_PORT` — port aplikacji. Pozstaw domyślny w docker. +- `LOG_LEVEL` — poziom logowania. +- `REDIS_URL` — opcjonalny Redis do rate-limitów, cache i zadań tła. +- `ARCHIVE_PATH`, `PDF_PATH`, `BACKUP_PATH`, `CERTS_PATH` — katalogi plików lokalnych. +- `APP_TIMEZONE` — strefa czasowa aplikacji. + +## Redis — jak podać i po co + +Poprawne przykłady: + +```env +REDIS_URL=redis://127.0.0.1:6379/0 +``` + +W Dockerze: + +```env +REDIS_URL=redis://redis:6379/0 +``` + +Jeśli `REDIS_URL` jest puste, aplikacja przechodzi na fallback `memory://`. + +Dla konfiguracji docker ustaw: +```env +REDIS_URL=redis://redis:6379/0 +``` + +## Ważne + +- Dane biznesowe wprowadza się z panelu WWW. +- Każda firma ma własną konfigurację KSeF, SMTP, Pushover, harmonogram i certyfikat. +- Harmonogram działa w tle także w Dockerze, w procesie aplikacji Flask. +- Ręczne pobieranie tylko pobiera dokumenty i generuje powiadomienia. Nie księguje i nie akceptuje faktur. +- Tryb mock KSeF służy do testów lokalnych. Synchronizacja i wystawianie działają lokalnie i nie wysyłają danych do środowiska produkcyjnego KSeF. + +## Docker + +```bash +docker compose up --build +``` +Start: +```bash +APP_DOMAIN=ksef.local:8785 ./deploy_docker.sh +``` + +## Konta + +Nie ma już wymogu seedów do logowania. Użyj CLI: + +```bash +flask --app run.py create-company +flask --app run.py create-user +``` + +## Role i dostęp do firm + +- `admin` — pełny dostęp i zarządzanie użytkownikami/firmami +- `operator` — praca operacyjna +- `readonly` — tylko odczyt +- na poziomie firmy można przypisać `full` albo `readonly` + +## CEIDG + +Klucz api konfigurowalny jest w panelu admina, +Hurtownia https://dane.biznes.gov.pl/pl/portal/034872, tu można złożyć wniosek o darmowy klucz API. + +## Migracja bazy w dockerze + +```docker compose run --rm web flask --app run.py db upgrade +``` \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..653cfde --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,165 @@ +from dotenv import load_dotenv +from pathlib import Path +from flask import Flask, render_template, request, url_for +from flask_login import current_user +from werkzeug.middleware.proxy_fix import ProxyFix +from dotenv import load_dotenv +from sqlalchemy import inspect, text +from sqlalchemy.exc import SQLAlchemyError +from config import Config +from redis.exceptions import RedisError +from app.cli import register_cli +from app.extensions import db, migrate, login_manager, csrf, limiter +from app.logging_config import configure_logging +from app.scheduler import init_scheduler +from app.utils.formatters import pln +import hashlib + + +def _ensure_storage(app): + Path(app.instance_path).mkdir(parents=True, exist_ok=True) + for key in ['ARCHIVE_PATH', 'PDF_PATH', 'BACKUP_PATH', 'CERTS_PATH']: + app.config[key].mkdir(parents=True, exist_ok=True) + + +def _ensure_column(table_name: str, column_name: str, ddl: str): + inspector = inspect(db.engine) + columns = {col['name'] for col in inspector.get_columns(table_name)} if inspector.has_table(table_name) else set() + if columns and column_name not in columns: + db.session.execute(text(f'ALTER TABLE {table_name} ADD COLUMN {ddl}')) + db.session.commit() + + +def _bootstrap_database(app): + try: + db.create_all() + patches = [ + ('user', 'theme_preference', "theme_preference VARCHAR(20) DEFAULT 'light' NOT NULL"), + ('user', 'is_blocked', 'is_blocked BOOLEAN DEFAULT 0 NOT NULL'), + ('user', 'force_password_change', 'force_password_change BOOLEAN DEFAULT 0 NOT NULL'), + ('app_setting', 'is_encrypted', 'is_encrypted BOOLEAN DEFAULT 0 NOT NULL'), + ('invoice', 'company_id', 'company_id INTEGER'), + ('invoice', 'source', "source VARCHAR(32) DEFAULT 'ksef' NOT NULL"), + ('invoice', 'customer_id', 'customer_id INTEGER'), + ('invoice', 'issued_to_ksef_at', 'issued_to_ksef_at DATETIME'), + ('invoice', 'issued_status', "issued_status VARCHAR(32) DEFAULT 'received' NOT NULL"), + ('sync_log', 'company_id', 'company_id INTEGER'), + ('sync_log', 'total', 'total INTEGER DEFAULT 0'), + ('company', 'note', 'note TEXT'), + ('company', 'regon', "regon VARCHAR(32) DEFAULT ''"), + ('company', 'address', "address VARCHAR(255) DEFAULT ''"), + ('customer', 'regon', "regon VARCHAR(32) DEFAULT ''"), + ] + for table, col, ddl in patches: + _ensure_column(table, col, ddl) + app.logger.info('Database bootstrap checked.') + except SQLAlchemyError: + app.logger.exception('Automatic database bootstrap failed.') + + +def _asset_hash(app: Flask, filename: str) -> str: + static_file = Path(app.static_folder) / filename + if not static_file.exists() or not static_file.is_file(): + return 'dev' + digest = hashlib.sha256(static_file.read_bytes()).hexdigest() + return digest[:12] + + +def create_app(config_class=Config): + load_dotenv() + app = Flask(__name__, instance_relative_config=True) + app.config.from_object(config_class) + app.config['RATELIMIT_STORAGE_URI'] = app.config.get('REDIS_URL', 'memory://') + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) + _ensure_storage(app) + configure_logging(app) + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + csrf.init_app(app) + from app.services.redis_service import RedisService + if app.config.get('RATELIMIT_STORAGE_URI', '').startswith('redis://') and not RedisService.available(app): + app.logger.warning('Redis niedostępny przy starcie - limiter przełączony na memory://') + app.config['RATELIMIT_STORAGE_URI'] = 'memory://' + limiter.init_app(app) + app.jinja_env.filters['pln'] = pln + from app.models import user, invoice, sync_log, notification, setting, audit_log, company, catalog # noqa + from app.auth.routes import bp as auth_bp + from app.dashboard.routes import bp as dashboard_bp + from app.invoices.routes import bp as invoices_bp + from app.settings.routes import bp as settings_bp + from app.notifications.routes import bp as notifications_bp + from app.api.routes import bp as api_bp + from app.admin.routes import bp as admin_bp + from app.nfz.routes import bp as nfz_bp + app.register_blueprint(auth_bp) + app.register_blueprint(dashboard_bp) + app.register_blueprint(invoices_bp) + app.register_blueprint(settings_bp) + app.register_blueprint(notifications_bp) + app.register_blueprint(admin_bp) + app.register_blueprint(nfz_bp) + app.register_blueprint(api_bp, url_prefix='/api') + register_cli(app) + + with app.app_context(): + if not app.config.get('TESTING'): + _bootstrap_database(app) + init_scheduler(app) + + @app.context_processor + def inject_globals(): + from app.models.setting import AppSetting + from app.services.company_service import CompanyService + theme = request.cookies.get('theme', 'light') + if getattr(current_user, 'is_authenticated', False): + theme = getattr(current_user, 'theme_preference', theme) or 'light' + else: + theme = AppSetting.get('ui.theme', theme) + current_company = CompanyService.get_current_company() if getattr(current_user, 'is_authenticated', False) else None + available_companies = CompanyService.available_for_user() if getattr(current_user, 'is_authenticated', False) else [] + nfz_enabled = False + if getattr(current_user, 'is_authenticated', False) and current_company: + from app.services.settings_service import SettingsService + nfz_enabled = SettingsService.get_effective('modules.nfz_enabled', 'false', company_id=current_company.id) == 'true' + status_map = {'sent': 'Wysłano', 'success': 'Sukces', 'error': 'Błąd', 'failed': 'Błąd', 'skipped': 'Pominięto', 'queued': 'Oczekuje'} + channel_map = {'email': 'E-mail', 'pushover': 'Pushover'} + return { + 'app_name': 'KSeF Manager', + 'theme': theme, + 'read_only_mode': (__import__('app.services.settings_service', fromlist=['SettingsService']).SettingsService.read_only_enabled(company_id=current_company.id if current_company else None) if getattr(current_user, 'is_authenticated', False) else False), + 'current_company': current_company, + 'available_companies': available_companies, + 'nfz_module_enabled': nfz_enabled, + 'static_asset': lambda filename: url_for('static', filename=filename, v=_asset_hash(app, filename)), + 'global_footer_text': app.config.get('APP_FOOTER_TEXT', ''), + 'status_pl': lambda value: status_map.get((value or '').lower(), value or '—'), + 'channel_pl': lambda value: channel_map.get((value or '').lower(), (value or '—').upper() if value else '—'), + } + + @app.after_request + def cleanup_static_headers(response): + if request.path.startswith('/static/'): + response.headers.pop('Content-Disposition', None) + return response + + @app.errorhandler(403) + def error_403(err): + return render_template('errors/403.html'), 403 + + @app.errorhandler(404) + def error_404(err): + return render_template('errors/404.html'), 404 + + @app.errorhandler(RedisError) + @app.errorhandler(ConnectionError) + def error_redis(err): + db.session.rollback() + return render_template('errors/503.html', message='Usługa cache jest chwilowo niedostępna. Aplikacja korzysta z trybu awaryjnego.'), 503 + + @app.errorhandler(500) + def error_500(err): + db.session.rollback() + return render_template('errors/500.html'), 500 + + return app diff --git a/app/admin/__init__.py b/app/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/routes.py b/app/admin/routes.py new file mode 100644 index 0000000..8bf37c2 --- /dev/null +++ b/app/admin/routes.py @@ -0,0 +1,498 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from decimal import Decimal +from pathlib import Path + +from werkzeug.security import generate_password_hash +from flask import Blueprint, abort, flash, redirect, render_template, request, send_file, url_for +from flask_login import current_user, login_required +from sqlalchemy import String, cast, or_ + +from app.extensions import db +from app.forms.admin import AdminCompanyForm, AdminUserForm, AccessForm, PasswordResetForm, CeidgConfigForm, DatabaseBackupForm, GlobalKsefDefaultsForm, GlobalMailSettingsForm, GlobalNfzSettingsForm, GlobalNotificationSettingsForm, LogCleanupForm, SharedCompanyKsefForm +from app.models.audit_log import AuditLog +from app.models.catalog import Customer, Product, InvoiceLine +from app.models.company import Company, UserCompanyAccess +from app.models.invoice import Invoice, InvoiceStatus, InvoiceType, MailDelivery, NotificationLog, SyncEvent +from app.models.setting import AppSetting +from app.models.sync_log import SyncLog +from app.models.user import User +from app.services.audit_service import AuditService +from app.services.backup_service import BackupService +from app.services.ceidg_service import CeidgService +from app.services.company_service import CompanyService +from app.services.health_service import HealthService +from app.services.ksef_service import RequestsKSeFAdapter +from app.services.settings_service import SettingsService +from app.services.system_data_service import SystemDataService +from app.utils.decorators import roles_required + +bp = Blueprint('admin', __name__, url_prefix='/admin') + + +def _mock_invoice_filter(): + metadata_text = cast(Invoice.external_metadata, String) + return or_( + Invoice.source == 'mock', + Invoice.issued_status == 'issued_mock', + Invoice.ksef_number.ilike('%MOCK%'), + Invoice.invoice_number.ilike('%MOCK%'), + metadata_text.ilike('%"source": "mock"%'), + metadata_text.ilike('%"mock"%'), + ) + + +def _cleanup_mock_catalog(company_ids: set[int]) -> dict: + deleted_customers = 0 + deleted_products = 0 + if not company_ids: + return {'customers': 0, 'products': 0} + + customer_candidates = Customer.query.filter(Customer.company_id.in_(company_ids)).all() + for customer in customer_candidates: + looks_like_demo = (customer.name or '').lower().startswith('klient demo') or (customer.email or '').lower() == 'demo@example.com' + if looks_like_demo and customer.invoices.count() == 0: + db.session.delete(customer) + deleted_customers += 1 + + product_candidates = Product.query.filter(Product.company_id.in_(company_ids)).all() + for product in product_candidates: + looks_like_demo = (product.name or '').lower() == 'abonament miesięczny' or (product.sku or '').upper() == 'SUB-MONTH' + linked_lines = InvoiceLine.query.filter_by(product_id=product.id).count() + if looks_like_demo and linked_lines == 0: + db.session.delete(product) + deleted_products += 1 + + return {'customers': deleted_customers, 'products': deleted_products} + + +def _admin_dashboard_context() -> dict: + mock_enabled = AppSetting.query.filter( + AppSetting.key.like('company.%.ksef.mock_mode'), + AppSetting.value == 'true' + ).count() + global_ro = AppSetting.get('app.read_only_mode', 'false') == 'true' + ceidg_environment = CeidgService.get_environment() + ceidg_form = CeidgConfigForm(environment=ceidg_environment) + ceidg_url = CeidgService.get_api_url(ceidg_environment) + cleanup_form = LogCleanupForm(days=90) + backup_form = DatabaseBackupForm() + + return { + 'users': User.query.count(), + 'companies': Company.query.count(), + 'audits': AuditLog.query.count(), + 'mock_enabled': mock_enabled, + 'global_ro': global_ro, + 'ceidg_form': ceidg_form, + 'ceidg_url': ceidg_url, + 'ceidg_api_key_configured': CeidgService.has_api_key(), + 'cleanup_form': cleanup_form, + 'backup_form': backup_form, + 'ceidg_environment': ceidg_environment, + 'backup_meta': BackupService().get_database_backup_meta(), + } + +@bp.route("/") +@login_required +@roles_required('admin') +def index(): + return render_template('admin/index.html', **_admin_dashboard_context()) + +@bp.route('/users') +@login_required +@roles_required('admin') +def users(): + return render_template('admin/users.html', users=User.query.order_by(User.name).all()) + + +@bp.route('/users/new', methods=['GET', 'POST']) +@bp.route('/users//edit', methods=['GET', 'POST']) +@login_required +@roles_required('admin') +def user_form(user_id=None): + user = db.session.get(User, user_id) if user_id else None + form = AdminUserForm(obj=user) + form.company_id.choices = [(0, '— bez przypisania —')] + [(c.id, c.name) for c in Company.query.order_by(Company.name).all()] + if request.method == 'GET' and user: + form.force_password_change.data = user.force_password_change + form.is_blocked.data = user.is_blocked + if form.validate_on_submit(): + if not user: + user = User(email=form.email.data.lower(), name=form.name.data, role=form.role.data, password_hash=generate_password_hash(form.password.data or 'ChangeMe123!')) + db.session.add(user) + db.session.flush() + else: + user.email = form.email.data.lower() + user.name = form.name.data + user.role = form.role.data + if form.password.data: + user.password_hash = generate_password_hash(form.password.data) + user.force_password_change = bool(form.force_password_change.data) + user.is_blocked = bool(form.is_blocked.data) + db.session.commit() + if form.company_id.data: + CompanyService.assign_user(user, db.session.get(Company, form.company_id.data), form.access_level.data) + AuditService().log('save_user', 'user', user.id, f'role={user.role}, blocked={user.is_blocked}') + flash('Zapisano użytkownika.', 'success') + return redirect(url_for('admin.user_access', user_id=user.id)) + accesses = UserCompanyAccess.query.filter_by(user_id=user.id).all() if user else [] + return render_template('admin/user_form.html', form=form, user=user, accesses=accesses) + + +@bp.route('/users//access', methods=['GET', 'POST']) +@login_required +@roles_required('admin') +def user_access(user_id): + user = db.session.get(User, user_id) + form = AccessForm() + form.company_id.choices = [(c.id, c.name) for c in Company.query.order_by(Company.name).all()] + if form.validate_on_submit(): + access = UserCompanyAccess.query.filter_by(user_id=user.id, company_id=form.company_id.data).first() + if not access: + access = UserCompanyAccess(user_id=user.id, company_id=form.company_id.data) + db.session.add(access) + access.access_level = form.access_level.data + db.session.commit() + AuditService().log('save_access', 'user', user.id, f'company={form.company_id.data}, level={form.access_level.data}') + flash('Zapisano uprawnienia do firmy.', 'success') + return redirect(url_for('admin.user_access', user_id=user.id)) + accesses = UserCompanyAccess.query.filter_by(user_id=user.id).all() + return render_template('admin/user_access.html', user=user, form=form, accesses=accesses) + + +@bp.post('/users//access//delete') +@login_required +@roles_required('admin') +def delete_access(user_id, access_id): + access = db.session.get(UserCompanyAccess, access_id) + if access and access.user_id == user_id: + db.session.delete(access) + db.session.commit() + AuditService().log('delete_access', 'user', user_id, f'access={access_id}') + flash('Usunięto dostęp.', 'info') + return redirect(url_for('admin.user_access', user_id=user_id)) + + +@bp.route('/users//reset-password', methods=['GET', 'POST']) +@login_required +@roles_required('admin') +def reset_password(user_id): + user = db.session.get(User, user_id) + form = PasswordResetForm() + if form.validate_on_submit(): + user.password_hash = generate_password_hash(form.password.data) + user.force_password_change = bool(form.force_password_change.data) + db.session.commit() + AuditService().log('reset_password', 'user', user.id, 'reset by admin') + flash('Hasło zostało zresetowane.', 'success') + return redirect(url_for('admin.users')) + return render_template('admin/reset_password.html', form=form, user=user) + + +@bp.post('/users//toggle-block') +@login_required +@roles_required('admin') +def toggle_block(user_id): + user = db.session.get(User, user_id) + user.is_blocked = not user.is_blocked + db.session.commit() + AuditService().log('toggle_block', 'user', user.id, f'blocked={user.is_blocked}') + flash('Zmieniono status blokady użytkownika.', 'warning') + return redirect(url_for('admin.users')) + + +@bp.route('/companies') +@login_required +@roles_required('admin') +def companies(): + return render_template('admin/companies.html', companies=Company.query.order_by(Company.name).all()) + + +@bp.route('/companies/new', methods=['GET', 'POST']) +@bp.route('/companies//edit', methods=['GET', 'POST']) +@login_required +@roles_required('admin') +def company_form(company_id=None): + company = db.session.get(Company, company_id) if company_id else None + form = AdminCompanyForm(obj=company) + if request.method == 'GET': + if company: + form.sync_interval_minutes.data = str(company.sync_interval_minutes) + form.mock_mode.data = AppSetting.get(f'company.{company.id}.ksef.mock_mode', 'false') == 'true' + else: + form.mock_mode.data = False + if form.fetch_submit.data and form.validate_on_submit(): + lookup = CeidgService().fetch_company(form.tax_id.data) + if lookup.get('ok'): + form.name.data = lookup.get('name') or form.name.data + form.regon.data = lookup.get('regon') or form.regon.data + form.address.data = lookup.get('address') or form.address.data + form.tax_id.data = lookup.get('tax_id') or form.tax_id.data + flash('Pobrano dane firmy z CEIDG.', 'success') + else: + flash(lookup.get('message', 'Nie udało się pobrać danych z CEIDG.'), 'warning') + elif form.submit.data and form.validate_on_submit(): + created = company is None + if not company: + company = Company() + db.session.add(company) + company.name = form.name.data + company.tax_id = form.tax_id.data or '' + company.regon = form.regon.data or '' + company.address = form.address.data or '' + company.bank_account = (form.bank_account.data or '').strip() + company.is_active = bool(form.is_active.data) + company.sync_enabled = bool(form.sync_enabled.data) + company.sync_interval_minutes = int(form.sync_interval_minutes.data or 60) + company.note = form.note.data or '' + db.session.commit() + AppSetting.set(f'company.{company.id}.ksef.mock_mode', str(bool(form.mock_mode.data)).lower()) + db.session.commit() + if created: + CompanyService.assign_user(user=current_user, company=company, access_level='full', switch_after=True) + AuditService().log('save_company', 'company', company.id, company.name) + flash('Zapisano firmę.', 'success') + return redirect(url_for('admin.companies')) + return render_template('admin/company_form.html', form=form, company=company) + + +@bp.post('/mock-data/generate') +@login_required +@roles_required('admin') +def generate_mock_data(): + companies = Company.query.order_by(Company.id).all() + for company in companies: + AppSetting.set(f'company.{company.id}.ksef.mock_mode', 'true') + if not Product.query.filter_by(company_id=company.id).first(): + db.session.add(Product(company_id=company.id, name='Abonament miesięczny', sku='SUB-MONTH', unit='usł.', net_price=Decimal('199.00'), vat_rate=Decimal('23'))) + if not Customer.query.filter_by(company_id=company.id).first(): + db.session.add(Customer(company_id=company.id, name=f'Klient demo {company.id}', tax_id=f'5250000{company.id:03d}', email='demo@example.com', address='Warszawa, Polska')) + db.session.flush() + if Invoice.query.filter_by(company_id=company.id).count() == 0: + customer = Customer.query.filter_by(company_id=company.id).first() + for idx in range(1, 4): + invoice = Invoice( + company_id=company.id, + customer_id=customer.id if customer else None, + ksef_number=f'MOCK/{company.id}/{idx}', + invoice_number=f'FV/{company.id}/{idx:03d}/2026', + contractor_name=customer.name if customer else f'Klient demo {company.id}', + contractor_nip=customer.tax_id if customer else f'5250000{company.id:03d}', + issue_date=datetime.utcnow().date() - timedelta(days=idx), + received_date=datetime.utcnow().date() - timedelta(days=idx), + fetched_at=datetime.utcnow(), + net_amount=Decimal('199.00'), + vat_amount=Decimal('45.77'), + gross_amount=Decimal('244.77'), + invoice_type=InvoiceType.SALE, + status=InvoiceStatus.SENT, + source='mock', + issued_status='sent', + issued_to_ksef_at=datetime.utcnow(), + ) + db.session.add(invoice) + db.session.flush() + db.session.commit() + AuditService().log('generate_mock_data', 'system', 0, f'companies={len(companies)}') + flash('Wygenerowano dane mock.', 'success') + return redirect(url_for('admin.index')) + + +@bp.post('/mock-data/clear') +@login_required +@roles_required('admin') +def clear_mock_data(): + invoices = Invoice.query.filter(_mock_invoice_filter()).all() + company_ids = {invoice.company_id for invoice in invoices if invoice.company_id} + deleted_invoices = 0 + for invoice in invoices: + db.session.delete(invoice) + deleted_invoices += 1 + + catalog_deleted = _cleanup_mock_catalog(company_ids) + + for company_id in company_ids: + AppSetting.set(f'company.{company_id}.ksef.mock_mode', 'false') + + db.session.commit() + AuditService().log( + 'clear_mock_data', + 'system', + 0, + f'invoices={deleted_invoices}, customers={catalog_deleted["customers"]}, products={catalog_deleted["products"]}, companies={len(company_ids)}', + ) + flash( + f'Usunięto dane mock: faktury {deleted_invoices}, klienci {catalog_deleted["customers"]}, produkty {catalog_deleted["products"]}.', + 'info', + ) + return redirect(url_for('admin.index')) + + +@bp.post('/ceidg/save') +@login_required +@roles_required('admin') +def save_ceidg_settings(): + form = CeidgConfigForm() + if form.validate_on_submit(): + environment = (form.environment.data or 'production').strip().lower() + if environment not in {'production', 'test'}: + environment = 'production' + + api_key = (form.api_key.data or '').strip() + + AppSetting.set('ceidg.environment', environment) + + api_key_updated = False + if api_key: + AppSetting.set('ceidg.api_key', api_key, encrypt=True) + api_key_updated = True + + db.session.commit() + AuditService().log( + 'save_ceidg_settings', + 'system', + 0, + f'environment={environment}, api_key_updated={api_key_updated}', + ) + flash('Zapisano konfigurację CEIDG.', 'success') + else: + flash('Nie udało się zapisać konfiguracji CEIDG.', 'danger') + return redirect(url_for('admin.index')) + + +@bp.post('/read-only/toggle') +@login_required +@roles_required('admin') +def toggle_global_read_only(): + enabled = request.form.get('enabled') == '1' + AppSetting.set('app.read_only_mode', 'true' if enabled else 'false') + db.session.commit() + AuditService().log('toggle_global_read_only', 'system', 0, f'enabled={enabled}') + flash('Zmieniono globalny tryb tylko do odczytu.', 'warning' if enabled else 'success') + return redirect(url_for('admin.index')) + + +@bp.post('/logs/cleanup') +@login_required +@roles_required('admin') +def cleanup_logs(): + form = LogCleanupForm() + if not form.validate_on_submit(): + flash('Podaj poprawną liczbę dni.', 'danger') + return redirect(url_for('admin.index')) + + cutoff = datetime.utcnow() - timedelta(days=form.days.data) + deleted = {} + targets = [ + ('audit', AuditLog, AuditLog.created_at), + ('sync', SyncLog, SyncLog.created_at), + ('notifications', NotificationLog, NotificationLog.created_at), + ('mail_delivery', MailDelivery, MailDelivery.created_at), + ('sync_events', SyncEvent, SyncEvent.created_at), + ] + for label, model, column in targets: + deleted[label] = model.query.filter(column < cutoff).delete(synchronize_session=False) + + removed_files = 0 + log_dir = Path('instance') + for pattern in ['app.log.*', '*.log.*']: + for file_path in log_dir.glob(pattern): + try: + if datetime.utcfromtimestamp(file_path.stat().st_mtime) < cutoff: + file_path.unlink() + removed_files += 1 + except OSError: + continue + + db.session.commit() + AuditService().log('cleanup_logs', 'system', 0, f'days={form.days.data}, deleted={deleted}, files={removed_files}') + flash(f'Usunięto stare logi starsze niż {form.days.data} dni. DB: {sum(deleted.values())}, pliki: {removed_files}.', 'success') + return redirect(url_for('admin.index')) + + +@bp.post('/database/backup') +@login_required +@roles_required('admin') +def database_backup(): + form = DatabaseBackupForm() + if not form.validate_on_submit(): + flash('Nie udało się uruchomić backupu bazy.', 'danger') + return redirect(url_for('admin.index')) + backup_path = BackupService().create_database_backup() + AuditService().log('database_backup', 'system', 0, backup_path) + return send_file(backup_path, as_attachment=True, download_name=Path(backup_path).name) + + + + + + +@bp.route('/global-settings', methods=['GET', 'POST']) +@login_required +@roles_required('admin') +def global_settings(): + current_company = CompanyService.get_current_company() + company_id = current_company.id if current_company else None + mail_form = GlobalMailSettingsForm(prefix='mail', server=SettingsService.get('mail.server', ''), port=SettingsService.get('mail.port', '587'), username=SettingsService.get('mail.username', ''), sender=SettingsService.get('mail.sender', ''), security_mode=(SettingsService.get('mail.security_mode', '') or ('tls' if SettingsService.get('mail.tls', 'true') == 'true' else 'none'))) + notify_form = GlobalNotificationSettingsForm(prefix='notify', pushover_user_key=SettingsService.get('notify.pushover_user_key', ''), min_amount=SettingsService.get('notify.min_amount', '0'), quiet_hours=SettingsService.get('notify.quiet_hours', ''), enabled=SettingsService.get('notify.enabled', 'false') == 'true') + nfz_form = GlobalNfzSettingsForm(prefix='nfz', enabled=SettingsService.get('modules.nfz_enabled', 'false') == 'true') + ksef_defaults_form = GlobalKsefDefaultsForm(prefix='kdef', environment=SettingsService.get('ksef.default_environment', 'prod'), auth_mode=SettingsService.get('ksef.default_auth_mode', 'token'), client_id=SettingsService.get('ksef.default_client_id', '')) + shared_ksef_form = SharedCompanyKsefForm(prefix='shared', environment=SettingsService.get('ksef.environment', 'prod', company_id=company_id), auth_mode=SettingsService.get('ksef.auth_mode', 'token', company_id=company_id), client_id=SettingsService.get('ksef.client_id', '', company_id=company_id), certificate_name=SettingsService.get('ksef.certificate_name', '', company_id=company_id)) + + if mail_form.submit.data and mail_form.validate_on_submit(): + SettingsService.set_many({'mail.server': mail_form.server.data or '', 'mail.port': mail_form.port.data or '587', 'mail.username': mail_form.username.data or '', 'mail.password': (mail_form.password.data or SettingsService.get_secret('mail.password', ''), True), 'mail.sender': mail_form.sender.data or '', 'mail.security_mode': mail_form.security_mode.data or 'tls', 'mail.tls': str((mail_form.security_mode.data or 'tls') == 'tls').lower()}) + flash('Zapisano globalne ustawienia SMTP.', 'success') + return redirect(url_for('admin.global_settings')) + + if notify_form.submit.data and notify_form.validate_on_submit(): + SettingsService.set_many({'notify.pushover_user_key': notify_form.pushover_user_key.data or '', 'notify.pushover_api_token': (notify_form.pushover_api_token.data or SettingsService.get_secret('notify.pushover_api_token', ''), True), 'notify.min_amount': notify_form.min_amount.data or '0', 'notify.quiet_hours': notify_form.quiet_hours.data or '', 'notify.enabled': str(bool(notify_form.enabled.data)).lower()}) + flash('Zapisano globalne ustawienia Pushover.', 'success') + return redirect(url_for('admin.global_settings')) + + if nfz_form.submit.data and nfz_form.validate_on_submit(): + SettingsService.set_many({'modules.nfz_enabled': str(bool(nfz_form.enabled.data)).lower()}) + flash('Zapisano globalne ustawienia modułu NFZ.', 'success') + return redirect(url_for('admin.global_settings')) + + if ksef_defaults_form.submit.data and ksef_defaults_form.validate_on_submit(): + SettingsService.set_many({'ksef.default_environment': ksef_defaults_form.environment.data or 'prod', 'ksef.default_auth_mode': ksef_defaults_form.auth_mode.data or 'token', 'ksef.default_client_id': ksef_defaults_form.client_id.data or ''}) + flash('Zapisano domyślne parametry KSeF.', 'success') + return redirect(url_for('admin.global_settings')) + + if shared_ksef_form.submit.data and shared_ksef_form.validate_on_submit() and company_id: + SettingsService.set_many({'ksef.environment': shared_ksef_form.environment.data or 'prod', 'ksef.base_url': RequestsKSeFAdapter.ENVIRONMENT_URLS.get(shared_ksef_form.environment.data or 'prod', RequestsKSeFAdapter.ENVIRONMENT_URLS['prod']), 'ksef.auth_mode': shared_ksef_form.auth_mode.data or 'token', 'ksef.client_id': shared_ksef_form.client_id.data or '', 'ksef.certificate_name': shared_ksef_form.certificate_name.data or '', 'ksef.token': (shared_ksef_form.token.data or SettingsService.get_secret('ksef.token', '', company_id=company_id), True), 'ksef.certificate_data': (shared_ksef_form.certificate_data.data or SettingsService.get_secret('ksef.certificate_data', '', company_id=company_id), True)}, company_id=company_id) + flash('Zapisano współdzielony profil KSeF dla aktywnej firmy.', 'success') + return redirect(url_for('admin.global_settings')) + + return render_template('admin/global_settings.html', mail_form=mail_form, notify_form=notify_form, nfz_form=nfz_form, ksef_defaults_form=ksef_defaults_form, shared_ksef_form=shared_ksef_form, current_company=current_company, shared_token_configured=bool(SettingsService.get_secret('ksef.token', '', company_id=company_id)) if company_id else False, shared_cert_configured=bool(SettingsService.get_secret('ksef.certificate_data', '', company_id=company_id)) if company_id else False) + +@bp.route('/maintenance') +@login_required +@roles_required('admin') +def maintenance(): + return render_template('admin/maintenance.html', **_admin_dashboard_context()) + + +@bp.route('/audit') +@login_required +@roles_required('admin') +def audit(): + logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(200).all() + return render_template('admin/audit.html', logs=logs) + + +@bp.route('/health') +@login_required +@roles_required('admin') +def health(): + return redirect(url_for('admin.system_data')) + + +@bp.route('/system-data') +@login_required +@roles_required('admin') +def system_data(): + data = SystemDataService().collect() + return render_template('admin/system_data.html', data=data, json_preview=SystemDataService.json_preview) diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..f5298a6 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,24 @@ +from flask import Blueprint, jsonify, request +from flask_login import login_required +from app.models.invoice import Invoice +from app.services.company_service import CompanyService +from app.services.health_service import HealthService + +bp = Blueprint('api', __name__) + + +@bp.route('/health') +def health(): + return jsonify(HealthService().get_status()) + + +@bp.route('/invoices') +@login_required +def invoices(): + company = CompanyService.get_current_company() + page = request.args.get('page', 1, type=int) + query = Invoice.query.order_by(Invoice.issue_date.desc()) + if company: + query = query.filter_by(company_id=company.id) + items = query.paginate(page=page, per_page=20, error_out=False) + return jsonify({'items': [{'id': i.id, 'invoice_number': i.invoice_number, 'ksef_number': i.ksef_number, 'contractor_name': i.contractor_name, 'gross_amount': float(i.gross_amount), 'status': i.status.value} for i in items.items], 'page': items.page, 'pages': items.pages, 'total': items.total}) diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..fb26103 --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,48 @@ +from datetime import datetime +from flask import Blueprint, flash, make_response, redirect, render_template, request, url_for, session +from flask_login import current_user, login_required, login_user, logout_user +from app.forms.auth import LoginForm +from app.extensions import db +from app.models.user import User + +bp = Blueprint('auth', __name__, url_prefix='/auth') + + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('dashboard.index')) + form = LoginForm() + response = None + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data.lower()).first() + if user and user.check_password(form.password.data): + if user.is_blocked: + flash('Konto użytkownika jest zablokowane.', 'danger') + else: + login_user(user) + user.last_login_at = datetime.utcnow() + first_company = user.companies()[0] if user.companies() else None + if first_company: + session['current_company_id'] = first_company.id + db.session.commit() + flash('Zalogowano pomyślnie.', 'success') + response = make_response(redirect(request.args.get('next') or url_for('dashboard.index'))) + response.set_cookie('theme', user.theme_preference or 'light', max_age=31536000, samesite='Lax') + return response + else: + flash('Błędny login lub hasło.', 'danger') + theme = request.cookies.get('theme', 'light') + return render_template('auth/login.html', form=form, theme=theme) + + +@bp.route('/logout') +@login_required +def logout(): + theme = current_user.theme_preference or request.cookies.get('theme', 'light') + logout_user() + session.pop('current_company_id', None) + flash('Wylogowano.', 'info') + response = make_response(redirect(url_for('auth.login'))) + response.set_cookie('theme', theme, max_age=31536000, samesite='Lax') + return response diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..1dc5ee4 --- /dev/null +++ b/app/cli.py @@ -0,0 +1,72 @@ +import click +from werkzeug.security import generate_password_hash +from app.extensions import db +from app.models.user import User +from app.models.company import Company, UserCompanyAccess +from app.seed import seed_data +from app.services.backup_service import BackupService +from app.services.mail_service import MailService +from app.services.pdf_service import PdfService +from app.services.sync_service import SyncService + + +def register_cli(app): + @app.cli.command('init-db') + def init_db(): + db.create_all() + print('Database initialized') + + @app.cli.command('create-company') + @click.option('--name', prompt=True) + @click.option('--tax-id', default='') + def create_company(name, tax_id): + company = Company(name=name, tax_id=tax_id) + db.session.add(company) + db.session.commit() + print(f'Company created: {company.id}') + + @app.cli.command('create-user') + @click.option('--email', prompt=True) + @click.option('--name', prompt=True) + @click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True) + @click.option('--role', default='admin', type=click.Choice(['admin', 'operator', 'readonly'])) + @click.option('--company-id', type=int, default=None) + @click.option('--access-level', default='full', type=click.Choice(['full', 'readonly'])) + def create_user(email, name, password, role, company_id, access_level): + if User.query.filter_by(email=email.lower()).first(): + raise click.ClickException('User already exists') + user = User(email=email.lower(), name=name, password_hash=generate_password_hash(password), role=role) + db.session.add(user) + db.session.flush() + if company_id: + db.session.add(UserCompanyAccess(user_id=user.id, company_id=company_id, access_level=access_level)) + db.session.commit() + print('User created') + + @app.cli.command('seed-data') + def seed_command(): + seed_data() + print('Seed data inserted') + + @app.cli.command('sync-ksef') + @click.option('--company-id', type=int, default=None) + def sync_ksef(company_id): + company = db.session.get(Company, company_id) if company_id else Company.query.first() + SyncService(company).run_manual_sync() + print('KSeF sync complete') + + @app.cli.command('rebuild-pdf') + def rebuild_pdf(): + PdfService().rebuild_all() + print('PDF rebuild complete') + + @app.cli.command('resend-mail') + @click.option('--delivery-id', prompt=True, type=int) + def resend_mail(delivery_id): + MailService().retry_delivery(delivery_id) + print('Mail resent') + + @app.cli.command('backup-data') + def backup_data(): + path = BackupService().create_backup() + print(f'Backup created: {path}') diff --git a/app/dashboard/__init__.py b/app/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/dashboard/routes.py b/app/dashboard/routes.py new file mode 100644 index 0000000..e36072f --- /dev/null +++ b/app/dashboard/routes.py @@ -0,0 +1,204 @@ +from datetime import date, datetime +from decimal import Decimal + +from flask import Blueprint, current_app, jsonify, redirect, render_template, request, url_for +from flask_login import current_user, login_required +from sqlalchemy import extract + +from app.extensions import csrf +from app.models.invoice import Invoice +from app.models.sync_log import SyncLog +from app.services.company_service import CompanyService +from app.services.health_service import HealthService +from app.services.redis_service import RedisService +from app.services.settings_service import SettingsService +from app.services.sync_service import SyncService + +bp = Blueprint('dashboard', __name__) + + +def _load_dashboard_summary(company_id: int): + cache_key = f'dashboard.summary.company.{company_id}' + cached = RedisService.get_json(cache_key) or {} + base = Invoice.query.filter_by(company_id=company_id) + today = date.today() + + if not cached: + month_invoices = base.filter( + extract('month', Invoice.issue_date) == today.month, + extract('year', Invoice.issue_date) == today.year, + ).order_by(Invoice.issue_date.desc(), Invoice.id.desc()).all() + cached = { + 'month_invoice_ids': [invoice.id for invoice in month_invoices], + 'unread': base.filter_by(is_unread=True).count(), + 'totals': { + 'net': str(sum(Decimal(invoice.net_amount) for invoice in month_invoices)), + 'vat': str(sum(Decimal(invoice.vat_amount) for invoice in month_invoices)), + 'gross': str(sum(Decimal(invoice.gross_amount) for invoice in month_invoices)), + }, + 'recent_invoice_ids': [invoice.id for invoice in base.order_by(Invoice.created_at.desc(), Invoice.id.desc()).limit(200).all()], + } + RedisService.set_json(cache_key, cached, ttl=300) + + month_ids = cached.get('month_invoice_ids', []) + month_invoices = Invoice.query.filter(Invoice.id.in_(month_ids)).all() if month_ids else [] + month_invoices.sort(key=lambda item: month_ids.index(item.id) if item.id in month_ids else 9999) + + totals = { + 'net': Decimal(str(cached.get('totals', {}).get('net', '0'))), + 'vat': Decimal(str(cached.get('totals', {}).get('vat', '0'))), + 'gross': Decimal(str(cached.get('totals', {}).get('gross', '0'))), + } + return cached, month_invoices, totals + + +@bp.route('/') +@login_required +def index(): + company = CompanyService.get_current_company() + health_service = HealthService() + health = health_service.get_cached_status(company.id if company else None) or health_service.get_status(company_id=company.id if company else None) + + if not company: + return render_template( + 'dashboard/index.html', + company=None, + month_invoices=[], + unread=0, + totals={'net': Decimal('0'), 'vat': Decimal('0'), 'gross': Decimal('0')}, + recent_invoices=[], + last_sync_display='brak', + sync_status='inactive', + health=health, + current_user=current_user, + recent_pagination={'page': 1, 'pages': 1, 'has_prev': False, 'has_next': False, 'prev_num': 1, 'next_num': 1}, + payment_details_map={}, + redis_fallback=(health.get('redis') == 'fallback'), + ) + + read_only = SettingsService.read_only_enabled(company_id=company.id) + base = Invoice.query.filter_by(company_id=company.id) + last_sync_raw = SettingsService.get('ksef.last_sync_at', None, company_id=company.id) + last_sync = None + if isinstance(last_sync_raw, str) and last_sync_raw.strip(): + try: + last_sync = datetime.fromisoformat(last_sync_raw.replace('Z', '+00:00')) + except Exception: + last_sync = last_sync_raw + elif last_sync_raw: + last_sync = last_sync_raw + if not last_sync: + latest_log = SyncLog.query.filter_by(company_id=company.id, status='finished').order_by(SyncLog.finished_at.desc()).first() + last_sync = latest_log.finished_at if latest_log and latest_log.finished_at else None + + cached, month_invoices, totals = _load_dashboard_summary(company.id) + unread = cached.get('unread', 0) + recent_ids = cached.get('recent_invoice_ids', []) + per_page = 10 + total_recent = len(recent_ids) + total_pages = max((total_recent + per_page - 1) // per_page, 1) + dashboard_page = min(max(request.args.get('dashboard_page', 1, type=int), 1), total_pages) + start = (dashboard_page - 1) * per_page + end = start + per_page + current_ids = recent_ids[start:end] + recent_invoices = Invoice.query.filter(Invoice.id.in_(current_ids)).all() if current_ids else [] + recent_invoices.sort(key=lambda item: current_ids.index(item.id) if item.id in current_ids else 9999) + recent_pagination = { + 'page': dashboard_page, + 'pages': total_pages, + 'has_prev': dashboard_page > 1, + 'has_next': end < total_recent, + 'prev_num': dashboard_page - 1, + 'next_num': dashboard_page + 1, + } + + from app.services.invoice_service import InvoiceService + payment_details_map = {invoice.id: InvoiceService().resolve_payment_details(invoice) for invoice in recent_invoices} + last_sync_display = last_sync.strftime('%Y-%m-%d %H:%M:%S') if hasattr(last_sync, 'strftime') else (last_sync or 'brak') + + return render_template( + 'dashboard/index.html', + company=company, + month_invoices=month_invoices, + unread=unread, + totals=totals, + recent_invoices=recent_invoices, + recent_pagination=recent_pagination, + payment_details_map=payment_details_map, + last_sync_display=last_sync_display, + last_sync_raw=last_sync, + sync_status=SettingsService.get('ksef.status', 'inactive', company_id=company.id), + health=health, + read_only=read_only, + redis_fallback=(health.get('redis') == 'fallback'), + ) + + +@bp.route('/switch-company/') +@login_required +def switch_company(company_id): + CompanyService.set_active_company(company_id) + return redirect(url_for('dashboard.index')) + + +@bp.post('/sync/manual') +@login_required +def manual_sync(): + company = CompanyService.get_current_company() + if not company: + return redirect(url_for('dashboard.index')) + app = current_app._get_current_object() + log_id = SyncService.start_manual_sync_async(app, company.id) + return redirect(url_for('dashboard.index', started=log_id)) + + +@bp.route('/sync/status') +@login_required +@csrf.exempt +def sync_status(): + company = CompanyService.get_current_company() + if not company: + return jsonify({'status': 'no_company'}) + log = SyncLog.query.filter_by(company_id=company.id).order_by(SyncLog.started_at.desc()).first() + if not log: + return jsonify({'status': 'idle'}) + return jsonify( + { + 'status': log.status, + 'message': log.message, + 'processed': log.processed, + 'created': log.created, + 'updated': log.updated, + 'errors': log.errors, + 'total': log.total, + } + ) + + +@bp.post('/sync/start') +@login_required +def sync_start(): + company = CompanyService.get_current_company() + if not company: + return jsonify({'error': 'no_company'}), 400 + app = current_app._get_current_object() + log_id = SyncService.start_manual_sync_async(app, company.id) + return jsonify({'log_id': log_id}) + + +@bp.get('/sync/status/') +@login_required +def sync_status_by_id(log_id): + log = SyncLog.query.get_or_404(log_id) + total = log.total or 0 + progress = int((log.processed / total) * 100) if total else (100 if log.status == 'finished' else 0) + return jsonify({ + 'status': log.status, + 'message': log.message, + 'processed': log.processed, + 'created': log.created, + 'updated': log.updated, + 'errors': log.errors, + 'total': total, + 'progress': progress, + }) diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..b833518 --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,17 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager +from flask_mail import Mail +from flask_wtf.csrf import CSRFProtect +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address + +db = SQLAlchemy() +migrate = Migrate() +login_manager = LoginManager() +login_manager.login_view = 'auth.login' +mail = Mail() +csrf = CSRFProtect() +limiter = Limiter(key_func=get_remote_address, default_limits=['300/day', '100/hour']) + +login_manager.login_message = None diff --git a/app/forms/__init__.py b/app/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/forms/admin.py b/app/forms/admin.py new file mode 100644 index 0000000..bde159a --- /dev/null +++ b/app/forms/admin.py @@ -0,0 +1,120 @@ +from flask_wtf import FlaskForm +from wtforms import ( + BooleanField, + HiddenField, + IntegerField, + PasswordField, + SelectField, + StringField, + SubmitField, + TextAreaField, +) +from wtforms.validators import DataRequired, Email, NumberRange, Optional + + +class AdminUserForm(FlaskForm): + email = StringField('E-mail', validators=[DataRequired(), Email()]) + name = StringField('Imię i nazwisko', validators=[DataRequired()]) + role = SelectField('Rola globalna', choices=[('admin', 'Admin'), ('operator', 'Operator'), ('readonly', 'Readonly')], validators=[DataRequired()]) + password = PasswordField('Hasło', validators=[Optional()]) + company_id = SelectField('Dodaj dostęp do firmy', coerce=int, validators=[Optional()]) + access_level = SelectField('Poziom dostępu do firmy', choices=[('full', 'Pełny'), ('readonly', 'Tylko odczyt')], validators=[Optional()]) + force_password_change = BooleanField('Wymuś zmianę hasła') + is_blocked = BooleanField('Zablokowany') + submit = SubmitField('Zapisz użytkownika') + + +class AccessForm(FlaskForm): + company_id = SelectField('Firma', coerce=int, validators=[DataRequired()]) + access_level = SelectField('Poziom dostępu', choices=[('full', 'Pełny'), ('readonly', 'Tylko odczyt')], validators=[DataRequired()]) + submit = SubmitField('Zapisz dostęp') + + +class PasswordResetForm(FlaskForm): + password = PasswordField('Nowe hasło', validators=[DataRequired()]) + force_password_change = BooleanField('Wymuś zmianę po logowaniu') + submit = SubmitField('Resetuj hasło') + + +class AdminCompanyForm(FlaskForm): + name = StringField('Nazwa firmy', validators=[Optional()]) + tax_id = StringField('NIP', validators=[Optional()]) + regon = StringField('REGON', validators=[Optional()]) + address = StringField('Adres', validators=[Optional()]) + bank_account = StringField('Numer rachunku bankowego', validators=[Optional()]) + is_active = BooleanField('Aktywna') + sync_enabled = BooleanField('Automatyczne pobieranie') + sync_interval_minutes = StringField('Interwał sync (min)', validators=[Optional()]) + note = TextAreaField('Opis / notatka', validators=[Optional()]) + mock_mode = BooleanField('Włącz tryb mock dla tej firmy') + submit = SubmitField('Zapisz firmę') + fetch_submit = SubmitField('Pobierz dane z CEIDG') + + def validate(self, extra_validators=None): + if not super().validate(extra_validators=extra_validators): + return False + is_fetch = bool(self.fetch_submit.data and not self.submit.data) + if is_fetch: + if not (self.tax_id.data or '').strip(): + self.tax_id.errors.append('Podaj NIP, aby pobrać dane z CEIDG.') + return False + return True + if not (self.name.data or '').strip(): + self.name.errors.append('To pole jest wymagane.') + return False + return True + + +class CeidgConfigForm(FlaskForm): + environment = HiddenField(default='production') + api_key = PasswordField('API KEY CEIDG', validators=[Optional()]) + submit = SubmitField('Zapisz konfigurację CEIDG') + + +class LogCleanupForm(FlaskForm): + days = IntegerField('Usuń logi starsze niż (dni)', validators=[DataRequired(), NumberRange(min=1, max=3650)]) + submit = SubmitField('Wyczyść logi') + + +class DatabaseBackupForm(FlaskForm): + submit = SubmitField('Wykonaj kopię bazy') + +class GlobalMailSettingsForm(FlaskForm): + server = StringField('SMTP host', validators=[Optional()]) + port = StringField('SMTP port', validators=[Optional()]) + username = StringField('SMTP login', validators=[Optional()]) + password = PasswordField('SMTP hasło', validators=[Optional()]) + sender = StringField('Nadawca', validators=[Optional(), Email()]) + security_mode = SelectField('Zabezpieczenie połączenia', choices=[('tls', 'TLS / STARTTLS'), ('ssl', 'SSL'), ('none', 'Brak')], validators=[Optional()], default='tls') + submit = SubmitField('Zapisz SMTP globalne') + + +class GlobalNotificationSettingsForm(FlaskForm): + pushover_user_key = StringField('Pushover user key', validators=[Optional()]) + pushover_api_token = PasswordField('Pushover API token', validators=[Optional()]) + min_amount = StringField('Powiadom od kwoty', validators=[Optional()]) + quiet_hours = StringField('Cichy harmonogram', validators=[Optional()]) + enabled = BooleanField('Włącz globalne powiadomienia') + submit = SubmitField('Zapisz Pushover globalnie') + + +class GlobalNfzSettingsForm(FlaskForm): + enabled = BooleanField('Włącz moduł NFZ globalnie') + submit = SubmitField('Zapisz NFZ globalnie') + + +class GlobalKsefDefaultsForm(FlaskForm): + environment = SelectField('Domyślne środowisko KSeF', choices=[('prod', 'PROD'), ('test', 'TEST')], validators=[Optional()]) + auth_mode = SelectField('Domyślny tryb autoryzacji', choices=[('token', 'Token'), ('certificate', 'Certyfikat')]) + client_id = StringField('Domyślny Client ID', validators=[Optional()]) + submit = SubmitField('Zapisz domyślne parametry KSeF') + + +class SharedCompanyKsefForm(FlaskForm): + environment = SelectField('Środowisko współdzielonego profilu', choices=[('prod', 'PROD'), ('test', 'TEST')], validators=[Optional()]) + auth_mode = SelectField('Tryb autoryzacji', choices=[('token', 'Token'), ('certificate', 'Certyfikat')]) + token = PasswordField('Token', validators=[Optional()]) + client_id = StringField('Client ID', validators=[Optional()]) + certificate_name = StringField('Nazwa certyfikatu', validators=[Optional()]) + certificate_data = PasswordField('Treść certyfikatu / base64', validators=[Optional()]) + submit = SubmitField('Zapisz współdzielony profil KSeF') diff --git a/app/forms/auth.py b/app/forms/auth.py new file mode 100644 index 0000000..a52b7b6 --- /dev/null +++ b/app/forms/auth.py @@ -0,0 +1,7 @@ +from flask_wtf import FlaskForm +from wtforms import PasswordField, StringField, SubmitField +from wtforms.validators import DataRequired, Email, Length +class LoginForm(FlaskForm): + email = StringField('E-mail', validators=[DataRequired(), Email()]) + password = PasswordField('Hasło', validators=[DataRequired(), Length(min=6)]) + submit = SubmitField('Zaloguj') diff --git a/app/forms/invoices.py b/app/forms/invoices.py new file mode 100644 index 0000000..25d226a --- /dev/null +++ b/app/forms/invoices.py @@ -0,0 +1,26 @@ +from flask_wtf import FlaskForm +from wtforms import BooleanField, SelectField, StringField, SubmitField, TextAreaField +from wtforms.validators import Optional + + +class InvoiceFilterForm(FlaskForm): + month = SelectField('Miesiąc', choices=[('', 'Wszystkie')] + [(str(i), str(i)) for i in range(1, 13)], validators=[Optional()]) + year = StringField('Rok', validators=[Optional()]) + contractor = StringField('Kontrahent', validators=[Optional()]) + nip = StringField('NIP', validators=[Optional()]) + invoice_type = SelectField('Typ', choices=[('', 'Wszystkie'), ('purchase', 'Zakupowa'), ('sale', 'Sprzedażowa'), ('correction', 'Korekta')], validators=[Optional()]) + status = SelectField('Status', choices=[('', 'Wszystkie'), ('new', 'Nowa'), ('read', 'Przeczytana'), ('accounted', 'Zaksięgowana'), ('sent', 'Wysłana'), ('archived', 'Archiwalna'), ('needs_attention', 'Wymaga uwagi'), ('error', 'Błąd')], validators=[Optional()]) + quick_filter = SelectField('Szybki filtr', choices=[('', 'Brak'), ('this_month', 'Ten miesiąc'), ('previous_month', 'Poprzedni miesiąc'), ('unread', 'Nieprzeczytane'), ('error', 'Z błędem'), ('to_send', 'Do wysyłki')], validators=[Optional()]) + min_amount = StringField('Min brutto', validators=[Optional()]) + max_amount = StringField('Max brutto', validators=[Optional()]) + search = StringField('Szukaj', validators=[Optional()]) + submit = SubmitField('Filtruj') + + +class InvoiceMetaForm(FlaskForm): + status = SelectField('Status', choices=[('new', 'Nowa'), ('read', 'Przeczytana'), ('accounted', 'Zaksięgowana'), ('sent', 'Wysłana'), ('archived', 'Archiwalna'), ('needs_attention', 'Wymaga uwagi'), ('error', 'Błąd')]) + tags = StringField('Tagi', validators=[Optional()]) + internal_note = TextAreaField('Notatka', validators=[Optional()]) + queue_accounting = BooleanField('Do księgowości') + pinned = BooleanField('Przypnij') + submit = SubmitField('Zapisz') diff --git a/app/forms/issued.py b/app/forms/issued.py new file mode 100644 index 0000000..351eb45 --- /dev/null +++ b/app/forms/issued.py @@ -0,0 +1,15 @@ +from flask_wtf import FlaskForm +from wtforms import BooleanField, DecimalField, SelectField, StringField, SubmitField +from wtforms.validators import DataRequired, Optional + + +class IssuedInvoiceForm(FlaskForm): + customer_id = SelectField('Klient', coerce=int, validators=[DataRequired()]) + numbering_template = SelectField('Format numeracji', choices=[('monthly', 'Miesięczny'), ('yearly', 'Roczny'), ('custom', 'Własny')], validators=[DataRequired()]) + invoice_number = StringField('Numer faktury', validators=[Optional()]) + product_id = SelectField('Towar / usługa', coerce=int, validators=[DataRequired()]) + quantity = DecimalField('Ilość', validators=[DataRequired()], default=1) + unit_net = DecimalField('Cena netto', validators=[Optional()]) + split_payment = BooleanField('Split payment') + save_submit = SubmitField('Generuj fakturę') + submit = SubmitField('Wyślij do KSeF') diff --git a/app/forms/nfz.py b/app/forms/nfz.py new file mode 100644 index 0000000..be09af7 --- /dev/null +++ b/app/forms/nfz.py @@ -0,0 +1,41 @@ +from flask_wtf import FlaskForm +from wtforms import DateField, DecimalField, SelectField, StringField, SubmitField +from wtforms.validators import DataRequired, Optional + + +NFZ_BRANCH_CHOICES = [ + ('1070001057-00018', 'Dolnośląski OW NFZ'), + ('1070001057-00021', 'Kujawsko-Pomorski OW NFZ'), + ('1070001057-00034', 'Lubelski OW NFZ'), + ('1070001057-00047', 'Lubuski OW NFZ'), + ('1070001057-00050', 'Łódzki OW NFZ'), + ('1070001057-00063', 'Małopolski OW NFZ'), + ('1070001057-00076', 'Mazowiecki OW NFZ'), + ('1070001057-00089', 'Opolski OW NFZ'), + ('1070001057-00092', 'Podkarpacki OW NFZ'), + ('1070001057-00106', 'Podlaski OW NFZ'), + ('1070001057-00119', 'Pomorski OW NFZ'), + ('1070001057-00122', 'Śląski OW NFZ'), + ('1070001057-00135', 'Świętokrzyski OW NFZ'), + ('1070001057-00148', 'Warmińsko-Mazurski OW NFZ'), + ('1070001057-00151', 'Wielkopolski OW NFZ'), + ('1070001057-00164', 'Zachodniopomorski OW NFZ'), + ('1070001057-00177', 'Centrala NFZ'), +] + + +class NfzInvoiceForm(FlaskForm): + customer_id = SelectField('Odbiorca techniczny', coerce=int, validators=[DataRequired()]) + product_id = SelectField('Towar / usługa', coerce=int, validators=[DataRequired()]) + invoice_number = StringField('Numer faktury', validators=[Optional()]) + nfz_branch_id = SelectField('Oddział NFZ (IDWew)', choices=NFZ_BRANCH_CHOICES, validators=[DataRequired()]) + settlement_from = DateField('Okres rozliczeniowy od', validators=[DataRequired()], format='%Y-%m-%d') + settlement_to = DateField('Okres rozliczeniowy do', validators=[DataRequired()], format='%Y-%m-%d') + template_identifier = StringField('Identyfikator szablonu', validators=[Optional()]) + provider_identifier = StringField('Identyfikator świadczeniodawcy', validators=[DataRequired()]) + service_code = StringField('Kod zakresu / wyróżnik / kod świadczenia', validators=[DataRequired()]) + contract_number = StringField('Numer umowy / aneksu', validators=[DataRequired()]) + quantity = DecimalField('Ilość', validators=[DataRequired()], default=1) + unit_net = DecimalField('Cena netto', validators=[DataRequired()]) + save_submit = SubmitField('Zapisz roboczo') + submit = SubmitField('Zapisz i wyślij do KSeF') diff --git a/app/forms/settings.py b/app/forms/settings.py new file mode 100644 index 0000000..985db32 --- /dev/null +++ b/app/forms/settings.py @@ -0,0 +1,73 @@ +from flask_wtf import FlaskForm +from flask_wtf.file import FileAllowed, FileField +from wtforms import BooleanField, IntegerField, PasswordField, RadioField, SelectField, StringField, SubmitField +from wtforms.validators import DataRequired, Email, Optional + + +class KSeFSettingsForm(FlaskForm): + source_mode = RadioField('Źródło ustawień', choices=[('user', 'Moje ustawienia'), ('global', 'Profil współdzielony firmy')], default='user') + environment = SelectField( + 'Środowisko KSeF', + choices=[('prod', 'PROD'), ('test', 'TEST')], + validators=[Optional()], + ) + auth_mode = SelectField('Tryb autoryzacji', choices=[('token', 'Token'), ('certificate', 'Certyfikat')]) + token = PasswordField('Token', validators=[Optional()]) + client_id = StringField('Client ID', validators=[Optional()]) + certificate_file = FileField('Certyfikat', validators=[Optional(), FileAllowed(['pem', 'crt', 'cer', 'p12', 'pfx'], 'Dozwolone: pem, crt, cer, p12, pfx')]) + submit = SubmitField('Zapisz KSeF') + + +class MailSettingsForm(FlaskForm): + source_mode = RadioField('Źródło SMTP', choices=[('global', 'Użyj ustawień globalnych'), ('user', 'Podaj indywidualne ustawienia')], default='global') + server = StringField('SMTP host', validators=[Optional()]) + port = StringField('SMTP port', validators=[Optional()]) + username = StringField('SMTP login', validators=[Optional()]) + password = PasswordField('SMTP hasło', validators=[Optional()]) + sender = StringField('Nadawca', validators=[Optional(), Email()]) + security_mode = SelectField('Zabezpieczenie połączenia', choices=[('tls', 'TLS / STARTTLS'), ('ssl', 'SSL'), ('none', 'Brak')], validators=[Optional()], default='tls') + test_recipient = StringField('Adres testowy', validators=[Optional(), Email()]) + submit = SubmitField('Zapisz SMTP') + test_submit = SubmitField('Wyślij test maila') + + +class NotificationSettingsForm(FlaskForm): + source_mode = RadioField('Źródło Pushover', choices=[('global', 'Użyj ustawień globalnych'), ('user', 'Podaj indywidualne ustawienia')], default='global') + pushover_user_key = StringField('Pushover user key', validators=[Optional()]) + pushover_api_token = PasswordField('Pushover API token', validators=[Optional()]) + min_amount = StringField('Powiadom od kwoty', validators=[Optional()]) + quiet_hours = StringField('Cichy harmonogram, np. 22:00-07:00', validators=[Optional()]) + enabled = BooleanField('Włącz powiadomienia') + submit = SubmitField('Zapisz powiadomienia') + test_submit = SubmitField('Wyślij test Pushover') + + +class AppearanceSettingsForm(FlaskForm): + theme_preference = SelectField('Motyw interfejsu', choices=[('light', 'Jasny'), ('dark', 'Ciemny')], validators=[DataRequired()]) + submit = SubmitField('Zapisz wygląd') + + +class CompanyForm(FlaskForm): + name = StringField('Nazwa firmy', validators=[DataRequired()]) + tax_id = StringField('NIP', validators=[Optional()]) + sync_enabled = BooleanField('Włącz harmonogram pobierania') + sync_interval_minutes = IntegerField('Interwał sync (min)', validators=[Optional()]) + bank_account = StringField('Numer rachunku bankowego', validators=[Optional()]) + read_only_mode = BooleanField('Tryb tylko odczyt (R/O)') + submit = SubmitField('Zapisz firmę') + + +class UserForm(FlaskForm): + email = StringField('E-mail', validators=[DataRequired(), Email()]) + name = StringField('Imię i nazwisko', validators=[DataRequired()]) + password = PasswordField('Hasło', validators=[Optional()]) + role = SelectField('Rola globalna', choices=[('admin', 'Admin'), ('operator', 'Operator'), ('readonly', 'Readonly')]) + company_id = SelectField('Firma', coerce=int, validators=[Optional()]) + access_level = SelectField('Dostęp do firmy', choices=[('full', 'Pełny'), ('readonly', 'Tylko odczyt')]) + submit = SubmitField('Dodaj / przypisz użytkownika') + + +class NfzModuleSettingsForm(FlaskForm): + source_mode = RadioField('Źródło konfiguracji NFZ', choices=[('global', 'Użyj ustawień globalnych'), ('user', 'Ustaw indywidualnie')], default='global') + enabled = BooleanField('Włącz moduł faktur NFZ') + submit = SubmitField('Zapisz moduł NFZ') \ No newline at end of file diff --git a/app/invoices/__init__.py b/app/invoices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/invoices/routes.py b/app/invoices/routes.py new file mode 100644 index 0000000..8e18d80 --- /dev/null +++ b/app/invoices/routes.py @@ -0,0 +1,725 @@ +from __future__ import annotations +from io import BytesIO +import csv +import zipfile +from decimal import Decimal + +from sqlalchemy import or_ + +from flask import Blueprint, Response, abort, flash, redirect, render_template, request, send_file, url_for +from flask_login import login_required + +from app.extensions import db +from app.forms.invoices import InvoiceFilterForm, InvoiceMetaForm +from app.forms.issued import IssuedInvoiceForm +from app.models.catalog import Customer, InvoiceLine, Product +from app.models.invoice import Invoice, InvoiceStatus, InvoiceType +from app.repositories.invoice_repository import InvoiceRepository +from app.services.audit_service import AuditService +from app.services.ceidg_service import CeidgService +from app.services.company_service import CompanyService +from app.services.invoice_service import InvoiceService +from app.services.ksef_service import KSeFService +from app.services.mail_service import MailService +from app.services.pdf_service import PdfService +from app.services.settings_service import SettingsService + +bp = Blueprint('invoices', __name__, url_prefix='/invoices') + + +def _company(): + return CompanyService.get_current_company() + + +def _require_company(redirect_endpoint='dashboard.index'): + company = _company() + if company: + return company + flash('Najpierw wybierz lub utwórz firmę, aby korzystać z tej sekcji.', 'warning') + return redirect(url_for(redirect_endpoint)) + + +def _invoice_or_404(invoice_id): + company = _company() + invoice = db.session.get(Invoice, invoice_id) + if not invoice or (company and invoice.company_id != company.id): + abort(404) + return invoice + + +def _ensure_full_access(company_id): + if SettingsService.read_only_enabled(company_id=company_id): + flash('Tryb tylko do odczytu jest aktywny dla tej firmy.', 'warning') + return False + return True + + +def _redirect_with_prefill(endpoint, *, customer_id=None, product_id=None, **kwargs): + if customer_id: + kwargs['created_customer_id'] = customer_id + if product_id: + kwargs['created_product_id'] = product_id + return redirect(url_for(endpoint, **kwargs)) + + +def _customer_from_invoice(invoice, company_id): + existing = None + if invoice.customer_id: + existing = db.session.get(Customer, invoice.customer_id) + if existing and existing.company_id == company_id: + return existing, False + + query = Customer.query.filter_by(company_id=company_id) + if invoice.contractor_nip: + existing = query.filter(Customer.tax_id == invoice.contractor_nip).first() + if not existing: + existing = query.filter(Customer.name == invoice.contractor_name).first() + + created = existing is None + customer = existing or Customer(company_id=company_id) + customer.name = customer.name or (invoice.contractor_name or '').strip() + customer.tax_id = customer.tax_id or (invoice.contractor_nip or '').strip() + customer.address = customer.address or (invoice.contractor_address or '').strip() + + if customer.tax_id and (not customer.address or not customer.regon): + lookup = CeidgService().fetch_company(customer.tax_id) + if lookup.get('ok'): + customer.name = customer.name or (lookup.get('name') or '').strip() + customer.tax_id = customer.tax_id or (lookup.get('tax_id') or '').strip() + customer.address = customer.address or (lookup.get('address') or '').strip() + customer.regon = customer.regon or (lookup.get('regon') or '').strip() + + db.session.add(customer) + db.session.flush() + if invoice.customer_id != customer.id: + invoice.customer_id = customer.id + return customer, created + + +@bp.route('/') +@login_required +def index(): + form = InvoiceFilterForm(request.args) + company = _company() + query = InvoiceRepository().query_filtered(request.args, company_id=company.id if company else None) + page = request.args.get('page', 1, type=int) + pagination = query.paginate(page=page, per_page=15, error_out=False) + payment_details_map = {invoice.id: InvoiceService().resolve_payment_details(invoice) for invoice in pagination.items} + return render_template('invoices/index.html', form=form, pagination=pagination, company=company, payment_details_map=payment_details_map) + + +@bp.route('/monthly') +@login_required +def monthly(): + company = _company() + period = request.args.get('period', 'month') + if period not in {'year', 'quarter', 'month'}: + period = 'month' + search = (request.args.get('q') or '').strip() + service = InvoiceService() + groups = service.grouped_summary(company_id=company.id if company else None, period=period, search=search) + comparisons = service.comparative_stats(company_id=company.id if company else None, search=search) + return render_template( + 'invoices/monthly.html', + groups=groups, + comparisons=comparisons, + company=company, + period=period, + period_title=service.period_title(period), + search=search + ) + + +@bp.route('/issued') +@login_required +def issued_list(): + company = _company() + search = (request.args.get('q') or '').strip() + page = request.args.get('page', 1, type=int) + query = Invoice.query.filter(Invoice.company_id == (company.id if company else None), Invoice.source.in_(['issued', 'nfz'])) + if search: + like = f'%{search}%' + query = query.filter(or_( + Invoice.invoice_number.ilike(like), + Invoice.ksef_number.ilike(like), + Invoice.contractor_name.ilike(like), + Invoice.contractor_nip.ilike(like) + )) + pagination = query.order_by(Invoice.created_at.desc()).paginate(page=page, per_page=15, error_out=False) + payment_details_map = {invoice.id: InvoiceService().resolve_payment_details(invoice) for invoice in pagination.items} + return render_template('invoices/issued_list.html', pagination=pagination, invoices=pagination.items, search=search, payment_details_map=payment_details_map) + + +@bp.route('/issued/new', methods=['GET', 'POST']) +@login_required +def issued_new(): + company = _company() + if not company: + return _require_company() + form = IssuedInvoiceForm(numbering_template='monthly') + customers = Customer.query.filter_by(company_id=company.id, is_active=True).order_by(Customer.name).all() + products = Product.query.filter_by(company_id=company.id, is_active=True).order_by(Product.name).all() + form.customer_id.choices = [(c.id, f'{c.name} ({c.tax_id})' if c.tax_id else c.name) for c in customers] + form.product_id.choices = [(p.id, f'{p.name} - {p.net_price} PLN') for p in products] + if request.method == 'GET': + duplicate_id = request.args.get('duplicate_id', type=int) + if duplicate_id: + src = _invoice_or_404(duplicate_id) + form.invoice_number.data = f'{src.invoice_number}/COPY' + form.numbering_template.data = 'custom' + if src.customer_id: + form.customer_id.data = src.customer_id + first_line = src.lines.first() + if first_line and first_line.product_id: + form.product_id.data = first_line.product_id + form.quantity.data = first_line.quantity + form.unit_net.data = first_line.unit_net + form.split_payment.data = bool(getattr(src, 'split_payment', False)) + else: + created_customer_id = request.args.get('created_customer_id', type=int) + created_product_id = request.args.get('created_product_id', type=int) + if created_customer_id and any(c.id == created_customer_id for c in customers): + form.customer_id.data = created_customer_id + elif customers: + form.customer_id.data = customers[0].id + if created_product_id and any(p.id == created_product_id for p in products): + form.product_id.data = created_product_id + elif products: + form.product_id.data = products[0].id + if products and form.product_id.data and not form.unit_net.data: + selected = next((p for p in products if p.id == form.product_id.data), None) + if selected: + form.unit_net.data = selected.net_price + form.split_payment.data = bool(selected.split_payment_default) + if customers and products and not form.invoice_number.data: + form.invoice_number.data = InvoiceService().next_sale_number(company.id, form.numbering_template.data or 'monthly') + if form.validate_on_submit(): + if not _ensure_full_access(company.id): + return redirect(url_for('invoices.issued_list')) + customer = db.session.get(Customer, form.customer_id.data) + product = db.session.get(Product, form.product_id.data) + qty = Decimal(str(form.quantity.data or 1)) + unit_net = Decimal(str(form.unit_net.data or product.net_price)) + net = (qty * unit_net).quantize(Decimal('0.01')) + vat = (net * (Decimal(str(product.vat_rate)) / Decimal('100'))).quantize(Decimal('0.01')) + gross = net + vat + split_payment = bool(form.split_payment.data or product.split_payment_default or gross > Decimal('15000')) + number = form.invoice_number.data or InvoiceService().next_sale_number(company.id, form.numbering_template.data) + send_to_ksef_now = bool(form.submit.data) + invoice = Invoice( + company_id=company.id, + customer_id=customer.id, + ksef_number=f'PENDING/{number}', + invoice_number=number, + contractor_name=customer.name, + contractor_nip=customer.tax_id, + issue_date=InvoiceService().today_date(), + received_date=InvoiceService().today_date(), + net_amount=net, + vat_amount=vat, + gross_amount=gross, + seller_bank_account=(company.bank_account or '').strip(), + split_payment=split_payment, + invoice_type=InvoiceType.SALE, + status=InvoiceStatus.NEW, + source='issued', + issued_status='draft' if not send_to_ksef_now else 'pending', + html_preview='', + external_metadata={'split_payment': split_payment, 'seller_bank_account': (company.bank_account or '').strip()}, + ) + db.session.add(invoice) + db.session.flush() + db.session.add(InvoiceLine( + invoice_id=invoice.id, + product_id=product.id, + description=product.name, + quantity=qty, + unit=product.unit, + unit_net=unit_net, + vat_rate=product.vat_rate, + net_amount=net, + vat_amount=vat, + gross_amount=gross, + )) + InvoiceService().persist_issued_assets(invoice) + if send_to_ksef_now: + payload = { + 'invoiceNumber': number, + 'customer': {'name': customer.name, 'taxId': customer.tax_id}, + 'lines': [{'name': product.name, 'qty': float(qty), 'unitNet': float(unit_net), 'vatRate': float(product.vat_rate)}], + 'metadata': {'split_payment': bool(invoice.split_payment)}, + } + result = KSeFService(company_id=company.id).issue_invoice(payload) + invoice.ksef_number = result.get('ksef_number', invoice.ksef_number) + invoice.issued_status = result.get('status', 'issued') + invoice.issued_to_ksef_at = InvoiceService().utcnow() + invoice.external_metadata = dict(invoice.external_metadata or {}, ksef_send=result) + flash(result.get('message', 'Wysłano fakturę do KSeF.'), 'success') + AuditService().log('send_invoice_to_ksef', 'invoice', invoice.id, invoice.ksef_number) + else: + flash('Wygenerowano fakturę roboczą. Możesz ją jeszcze poprawić przed wysyłką do KSeF.', 'success') + AuditService().log('draft_invoice', 'invoice', invoice.id, invoice.invoice_number) + db.session.commit() + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + preview_number = form.invoice_number.data or InvoiceService().next_sale_number(company.id, form.numbering_template.data or 'monthly') if customers and products else '' + return render_template('invoices/issued_form.html', form=form, customers=customers, products=products, preview_number=preview_number) + + +@bp.post('/issued//send-to-ksef') +@login_required +def send_to_ksef(invoice_id): + invoice = _invoice_or_404(invoice_id) + if invoice.source != 'issued': + abort(400) + if not _ensure_full_access(invoice.company_id): + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + if InvoiceService.invoice_locked(invoice): + flash('Ta faktura została już wysłana do KSeF i jest zablokowana do edycji.', 'warning') + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + if invoice.gross_amount > Decimal('15000'): + invoice.split_payment = True + invoice.external_metadata = dict(invoice.external_metadata or {}, split_payment=True) + first_line = invoice.lines.first() + payload = { + 'invoiceNumber': invoice.invoice_number, + 'customer': {'name': invoice.contractor_name, 'taxId': invoice.contractor_nip}, + 'metadata': {'split_payment': bool(invoice.split_payment)}, + 'lines': [{ + 'name': first_line.description if first_line else invoice.invoice_number, + 'qty': float(first_line.quantity if first_line else 1), + 'unitNet': float(first_line.unit_net if first_line else invoice.net_amount), + 'vatRate': float(first_line.vat_rate if first_line else 23), + }], + } + result = KSeFService(company_id=invoice.company_id).issue_invoice(payload) + invoice.ksef_number = result.get('ksef_number', invoice.ksef_number) + invoice.issued_status = result.get('status', 'issued') + invoice.issued_to_ksef_at = InvoiceService().utcnow() + invoice.external_metadata = dict(invoice.external_metadata or {}, ksef_send=result) + InvoiceService().persist_issued_assets(invoice) + db.session.commit() + flash(result.get('message', 'Wysłano fakturę do KSeF.'), 'success') + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + + +@bp.route('/customers', methods=['GET', 'POST']) +@login_required +def customers(): + company = _company() + if not company: + return _require_company() + customer_id = request.args.get('customer_id', type=int) + editing = db.session.get(Customer, customer_id) if customer_id else None + if editing and editing.company_id != company.id: + editing = None + if request.method == 'POST' and request.form.get('fetch_ceidg') and _ensure_full_access(company.id): + lookup = CeidgService().fetch_company(request.form.get('tax_id')) + if lookup.get('ok'): + editing = editing or Customer(company_id=company.id) + editing.name = lookup.get('name') or editing.name + editing.tax_id = lookup.get('tax_id') or editing.tax_id + editing.address = lookup.get('address') or editing.address + editing.regon = lookup.get('regon') or editing.regon + flash('Pobrano dane klienta z rejestru przedsiębiorców.', 'success') + else: + flash(lookup.get('message', 'Nie udało się pobrać danych z CEIDG.'), 'warning') + elif request.method == 'POST' and _ensure_full_access(company.id): + target = editing or Customer(company_id=company.id) + target.name = request.form.get('name', '').strip() + target.tax_id = request.form.get('tax_id', '').strip() + target.email = request.form.get('email', '').strip() + target.address = request.form.get('address', '').strip() + target.regon = request.form.get('regon', '').strip() + db.session.add(target) + db.session.commit() + flash('Zapisano klienta.', 'success') + return redirect(url_for('invoices.customers')) + search = (request.args.get('q') or '').strip() + page = request.args.get('page', 1, type=int) + sort = (request.args.get('sort') or 'name_asc').strip() + query = Customer.query.filter_by(company_id=company.id) + if search: + like = f'%{search}%' + query = query.filter(or_( + Customer.name.ilike(like), + Customer.tax_id.ilike(like), + Customer.regon.ilike(like), + Customer.email.ilike(like), + Customer.address.ilike(like) + )) + sort_map = { + 'name_asc': Customer.name.asc(), + 'name_desc': Customer.name.desc(), + 'tax_id_asc': Customer.tax_id.asc(), + 'tax_id_desc': Customer.tax_id.desc(), + } + pagination = query.order_by(sort_map.get(sort, Customer.name.asc()), Customer.id.asc()).paginate(page=page, per_page=15, error_out=False) + return render_template('invoices/customers.html', items=pagination.items, pagination=pagination, editing=editing, search=search, sort=sort) + + +@bp.route('/products', methods=['GET', 'POST']) +@login_required +def products(): + company = _company() + if not company: + return _require_company() + product_id = request.args.get('product_id', type=int) + editing = db.session.get(Product, product_id) if product_id else None + if editing and editing.company_id != company.id: + editing = None + if request.method == 'POST' and _ensure_full_access(company.id): + target = editing or Product(company_id=company.id) + target.name = request.form.get('name', '').strip() + target.sku = request.form.get('sku', '').strip() + target.unit = request.form.get('unit', 'szt.').strip() + target.net_price = Decimal(request.form.get('net_price', '0') or '0') + target.vat_rate = Decimal(request.form.get('vat_rate', '23') or '23') + target.split_payment_default = bool(request.form.get('split_payment_default')) + db.session.add(target) + db.session.commit() + flash('Zapisano towar/usługę.', 'success') + return redirect(url_for('invoices.products')) + search = (request.args.get('q') or '').strip() + page = request.args.get('page', 1, type=int) + sort = (request.args.get('sort') or 'name_asc').strip() + query = Product.query.filter_by(company_id=company.id) + if search: + like = f'%{search}%' + query = query.filter(or_(Product.name.ilike(like), Product.sku.ilike(like), Product.unit.ilike(like))) + sort_map = { + 'name_asc': Product.name.asc(), + 'name_desc': Product.name.desc(), + 'price_asc': Product.net_price.asc(), + 'price_desc': Product.net_price.desc(), + 'sku_asc': Product.sku.asc(), + 'sku_desc': Product.sku.desc(), + } + pagination = query.order_by(sort_map.get(sort, Product.name.asc()), Product.id.asc()).paginate(page=page, per_page=15, error_out=False) + return render_template('invoices/products.html', items=pagination.items, pagination=pagination, editing=editing, search=search, sort=sort) + + +@bp.post('/customers/quick-create') +@login_required +def quick_create_customer(): + company = _company() + if not company: + return _require_company() + if not _ensure_full_access(company.id): + return redirect(request.form.get('return_to') or url_for('invoices.issued_new')) + name = (request.form.get('name') or '').strip() + if not name: + flash('Podaj nazwę klienta.', 'warning') + return redirect(request.form.get('return_to') or url_for('invoices.issued_new')) + customer = Customer( + company_id=company.id, + name=name, + tax_id=(request.form.get('tax_id') or '').strip(), + email=(request.form.get('email') or '').strip(), + address=(request.form.get('address') or '').strip(), + regon=(request.form.get('regon') or '').strip() + ) + db.session.add(customer) + db.session.commit() + flash('Dodano klienta i ustawiono go w formularzu.', 'success') + target = request.form.get('return_endpoint') or 'invoices.issued_new' + target_kwargs = {} + invoice_id = request.form.get('invoice_id', type=int) + if invoice_id and target in {'invoices.issued_edit', 'nfz.edit'}: + target_kwargs['invoice_id'] = invoice_id + return _redirect_with_prefill(target, customer_id=customer.id, **target_kwargs) + + +@bp.post('/products/quick-create') +@login_required +def quick_create_product(): + company = _company() + if not company: + return _require_company() + if not _ensure_full_access(company.id): + return redirect(request.form.get('return_to') or url_for('invoices.issued_new')) + name = (request.form.get('name') or '').strip() + if not name: + flash('Podaj nazwę towaru lub usługi.', 'warning') + return redirect(request.form.get('return_to') or url_for('invoices.issued_new')) + product = Product( + company_id=company.id, + name=name, + sku=(request.form.get('sku') or '').strip(), + unit=(request.form.get('unit') or 'szt.').strip(), + net_price=Decimal(request.form.get('net_price', '0') or '0'), + vat_rate=Decimal(request.form.get('vat_rate', '23') or '23'), + split_payment_default=bool(request.form.get('split_payment_default')) + ) + db.session.add(product) + db.session.commit() + flash('Dodano towar/usługę i ustawiono w formularzu.', 'success') + target = request.form.get('return_endpoint') or 'invoices.issued_new' + target_kwargs = {} + invoice_id = request.form.get('invoice_id', type=int) + if invoice_id and target in {'invoices.issued_edit', 'nfz.edit'}: + target_kwargs['invoice_id'] = invoice_id + return _redirect_with_prefill(target, product_id=product.id, **target_kwargs) + + +@bp.route('/issued//edit', methods=['GET', 'POST']) +@login_required +def issued_edit(invoice_id): + invoice = _invoice_or_404(invoice_id) + if invoice.source != 'issued': + abort(400) + if InvoiceService.invoice_locked(invoice): + flash('Ta faktura została już wysłana do KSeF i nie można jej edytować.', 'warning') + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + company = invoice.company + if not company or not _ensure_full_access(company.id): + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + form = IssuedInvoiceForm(numbering_template='custom') + customers = Customer.query.filter_by(company_id=company.id, is_active=True).order_by(Customer.name).all() + products = Product.query.filter_by(company_id=company.id, is_active=True).order_by(Product.name).all() + form.customer_id.choices = [(c.id, f'{c.name} ({c.tax_id})' if c.tax_id else c.name) for c in customers] + form.product_id.choices = [(p.id, f'{p.name} - {p.net_price} PLN') for p in products] + line = invoice.lines.first() + if request.method == 'GET': + created_customer_id = request.args.get('created_customer_id', type=int) + created_product_id = request.args.get('created_product_id', type=int) + form.customer_id.data = created_customer_id if created_customer_id and any(c.id == created_customer_id for c in customers) else (invoice.customer_id or (customers[0].id if customers else None)) + form.invoice_number.data = invoice.invoice_number + form.product_id.data = created_product_id if created_product_id and any(p.id == created_product_id for p in products) else (line.product_id if line and line.product_id else (products[0].id if products else None)) + form.quantity.data = line.quantity if line else 1 + form.unit_net.data = line.unit_net if line else invoice.net_amount + form.split_payment.data = bool(invoice.split_payment or (form.product_id.data and next((p.split_payment_default for p in products if p.id == form.product_id.data), False))) + if created_product_id: + selected = next((p for p in products if p.id == created_product_id), None) + if selected: + form.unit_net.data = selected.net_price + if form.validate_on_submit(): + customer = db.session.get(Customer, form.customer_id.data) + product = db.session.get(Product, form.product_id.data) + qty = Decimal(str(form.quantity.data or 1)) + unit_net = Decimal(str(form.unit_net.data or product.net_price)) + net = (qty * unit_net).quantize(Decimal('0.01')) + vat = (net * (Decimal(str(product.vat_rate)) / Decimal('100'))).quantize(Decimal('0.01')) + gross = net + vat + invoice.split_payment = bool(form.split_payment.data or product.split_payment_default or gross > Decimal('15000')) + invoice.customer_id = customer.id + invoice.invoice_number = form.invoice_number.data or invoice.invoice_number + invoice.contractor_name = customer.name + invoice.contractor_nip = customer.tax_id + invoice.net_amount = net + invoice.vat_amount = vat + invoice.gross_amount = gross + invoice.seller_bank_account = (company.bank_account or '').strip() + invoice.ksef_number = f'PENDING/{invoice.invoice_number}' + if not line: + line = InvoiceLine(invoice_id=invoice.id) + db.session.add(line) + line.product_id = product.id + line.description = product.name + line.quantity = qty + line.unit = product.unit + line.unit_net = unit_net + line.vat_rate = product.vat_rate + line.net_amount = net + line.vat_amount = vat + line.gross_amount = gross + InvoiceService().persist_issued_assets(invoice) + invoice.external_metadata = dict(invoice.external_metadata or {}, split_payment=invoice.split_payment, seller_bank_account=invoice.seller_bank_account) + invoice.issued_status = 'draft' + db.session.commit() + flash('Zapisano zmiany w fakturze roboczej.', 'success') + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + preview_number = form.invoice_number.data or invoice.invoice_number + return render_template( + 'invoices/issued_form.html', + form=form, + customers=customers, + products=products, + preview_number=preview_number, + editing_invoice=invoice + ) + + +@bp.route('/', methods=['GET', 'POST']) +@login_required +def detail(invoice_id): + invoice = _invoice_or_404(invoice_id) + service = InvoiceService() + service.mark_read(invoice) + locked = read_only = SettingsService.read_only_enabled(company_id=invoice.company_id) or InvoiceService.invoice_locked(invoice) + form = InvoiceMetaForm( + status=invoice.status.value, + tags=', '.join([t.name for t in invoice.tags]), + internal_note=invoice.internal_note, + queue_accounting=invoice.queue_accounting, + pinned=invoice.pinned + ) + if form.validate_on_submit() and not locked: + service.update_metadata(invoice, form) + AuditService().log('update', 'invoice', invoice.id, 'Updated metadata') + flash('Zapisano zmiany.', 'success') + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + + xml_content = '' + if invoice.xml_path: + from pathlib import Path as _Path + path = _Path(invoice.xml_path) + if path.exists(): + xml_content = path.read_text(encoding='utf-8') + + invoice.html_preview = service.render_invoice_html(invoice) + linked_customer = db.session.get(Customer, invoice.customer_id) if invoice.customer_id else None + can_add_seller_customer = bool(invoice.contractor_name) and invoice.source not in ['issued', 'nfz'] + + if invoice.source in ['issued', 'nfz']: + if not xml_content or (invoice.source == 'issued' and not invoice.xml_path): + xml_content = service.persist_issued_assets(invoice) + else: + PdfService().render_invoice_pdf(invoice) + else: + if invoice.xml_path: + PdfService().render_invoice_pdf(invoice) + + db.session.commit() + payment_details = service.resolve_payment_details(invoice) + return render_template( + 'invoices/detail.html', + invoice=invoice, + form=form, + xml_content=xml_content, + edit_locked=locked, + linked_customer=linked_customer, + can_add_seller_customer=can_add_seller_customer, + payment_details=payment_details, + ) + + +@bp.route('//duplicate') +@login_required +def duplicate(invoice_id): + invoice = _invoice_or_404(invoice_id) + if invoice.source == 'nfz': + return redirect(url_for('nfz.index', duplicate_id=invoice_id)) + return redirect(url_for('invoices.issued_new', duplicate_id=invoice_id)) + + +@bp.route('//pdf') +@login_required +def pdf(invoice_id): + invoice = _invoice_or_404(invoice_id) + pdf_bytes, _ = PdfService().render_invoice_pdf(invoice) + return send_file(BytesIO(pdf_bytes), download_name=f'{invoice.invoice_number}.pdf', mimetype='application/pdf') + + +@bp.route('//send', methods=['POST']) +@login_required +def send(invoice_id): + invoice = _invoice_or_404(invoice_id) + if not _ensure_full_access(invoice.company_id): + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + + recipient = (request.form.get('recipient') or '').strip() + if not recipient: + flash('Brak adresu e-mail odbiorcy.', 'warning') + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + + delivery = MailService(company_id=invoice.company_id).send_invoice(invoice, recipient) + + if delivery.status == 'sent': + flash(f'Fakturę wysłano na adres: {delivery.recipient}.', 'success') + else: + error_message = (delivery.error_message or 'Nieznany błąd SMTP').strip() + flash( + f'Nie udało się wysłać faktury na adres: {delivery.recipient}. Błąd: {error_message}', + 'danger' + ) + + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + + +@bp.route('/export/csv') +@login_required +def export_csv(): + company = _company() + rows = InvoiceRepository().query_filtered(request.args, company_id=company.id if company else None).all() + import io + sio = io.StringIO() + writer = csv.writer(sio) + writer.writerow(['Firma', 'Numer', 'KSeF', 'Kontrahent', 'NIP', 'Data', 'Netto', 'VAT', 'Brutto', 'Typ', 'Status']) + for invoice in rows: + writer.writerow([ + invoice.company.name if invoice.company else '', + invoice.invoice_number, + invoice.ksef_number, + invoice.contractor_name, + invoice.contractor_nip, + invoice.issue_date.isoformat(), + str(invoice.net_amount), + str(invoice.vat_amount), + str(invoice.gross_amount), + invoice.invoice_type.value, + invoice.status.value + ]) + return Response(sio.getvalue(), mimetype='text/csv', headers={'Content-Disposition': 'attachment; filename=invoices.csv'}) + + +@bp.route('/export/zip') +@login_required +def export_zip(): + company = _company() + rows = InvoiceRepository().query_filtered(request.args, company_id=company.id if company else None).all() + mem = BytesIO() + with zipfile.ZipFile(mem, mode='w', compression=zipfile.ZIP_DEFLATED) as zf: + for invoice in rows: + if invoice.xml_path: + from pathlib import Path + path = Path(invoice.xml_path) + if path.exists(): + zf.write(path, arcname=path.name) + mem.seek(0) + return send_file(mem, download_name='invoices.zip', mimetype='application/zip') + + +@bp.route('/monthly//pdf') +@login_required +def month_pdf(period): + company = _company() + groups = [g for g in InvoiceService().grouped_summary(company_id=company.id if company else None, period='month') if g['key'] == period] + if not groups: + return redirect(url_for('invoices.monthly')) + pdf_bytes = PdfService().month_pdf(groups[0]['entries'], f'Miesiąc {period}') + return send_file(BytesIO(pdf_bytes), download_name=f'{period}.pdf', mimetype='application/pdf') + + +@bp.route('/bulk', methods=['POST']) +@login_required +def bulk_action(): + company = _company() + if SettingsService.read_only_enabled(company_id=company.id if company else None): + flash('Tryb read only jest aktywny.', 'warning') + return redirect(url_for('invoices.index')) + ids = request.form.getlist('invoice_ids') + action = request.form.get('action') + invoices = Invoice.query.filter(Invoice.company_id == (company.id if company else None), Invoice.id.in_(ids)).all() + for invoice in invoices: + if action == 'mark_accounted': + invoice.status = invoice.status.__class__.ACCOUNTED + elif action == 'queue_accounting': + invoice.queue_accounting = True + db.session.commit() + flash('Wykonano akcję masową.', 'success') + return redirect(url_for('invoices.index')) + +@bp.post('//add-seller-customer') +@login_required +def add_seller_customer(invoice_id): + invoice = _invoice_or_404(invoice_id) + if invoice.source in ['issued', 'nfz']: + flash('Dla faktur sprzedażowych kontrahent jest już obsługiwany w kartotece odbiorców.', 'info') + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + if not _ensure_full_access(invoice.company_id): + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + + customer, created = _customer_from_invoice(invoice, invoice.company_id) + db.session.commit() + flash('Dodano kontrahenta do kartoteki klientów.' if created else 'Powiązano kontrahenta z istniejącą kartoteką klientów.', 'success') + return redirect(url_for('invoices.customers', customer_id=customer.id)) \ No newline at end of file diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000..5ec2da8 --- /dev/null +++ b/app/logging_config.py @@ -0,0 +1,12 @@ +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + +def configure_logging(app): + log_dir = Path('instance') + log_dir.mkdir(exist_ok=True) + handler = RotatingFileHandler(log_dir / 'app.log', maxBytes=1_000_000, backupCount=5) + handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s [%(name)s] %(message)s')) + app.logger.setLevel(app.config['LOG_LEVEL']) + if not app.logger.handlers: + app.logger.addHandler(handler) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/audit_log.py b/app/models/audit_log.py new file mode 100644 index 0000000..fb2f6ed --- /dev/null +++ b/app/models/audit_log.py @@ -0,0 +1,10 @@ +from app.extensions import db +from app.models.base import TimestampMixin +class AuditLog(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + action = db.Column(db.String(64), nullable=False) + target_type = db.Column(db.String(64), nullable=False) + target_id = db.Column(db.Integer) + remote_addr = db.Column(db.String(64)) + details = db.Column(db.Text) diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..1971615 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,6 @@ +from datetime import datetime +from app.extensions import db + +class TimestampMixin: + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) diff --git a/app/models/catalog.py b/app/models/catalog.py new file mode 100644 index 0000000..d6e6372 --- /dev/null +++ b/app/models/catalog.py @@ -0,0 +1,50 @@ +from app.extensions import db +from app.models.base import TimestampMixin + + +class Customer(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False, index=True) + name = db.Column(db.String(255), nullable=False) + tax_id = db.Column(db.String(32), index=True, default='') + email = db.Column(db.String(255), default='') + address = db.Column(db.String(255), default='') + regon = db.Column(db.String(32), default='') + is_active = db.Column(db.Boolean, default=True, nullable=False) + + company = db.relationship('Company', backref=db.backref('customers', lazy='dynamic', cascade='all, delete-orphan')) + + __table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_customer_company_name'),) + + +class Product(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False, index=True) + name = db.Column(db.String(255), nullable=False) + sku = db.Column(db.String(64), default='') + unit = db.Column(db.String(16), default='szt.') + net_price = db.Column(db.Numeric(12, 2), nullable=False, default=0) + vat_rate = db.Column(db.Numeric(5, 2), nullable=False, default=23) + split_payment_default = db.Column(db.Boolean, default=False, nullable=False) + is_active = db.Column(db.Boolean, default=True, nullable=False) + + company = db.relationship('Company', backref=db.backref('products', lazy='dynamic', cascade='all, delete-orphan')) + + __table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_product_company_name'),) + + +class InvoiceLine(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id'), nullable=False, index=True) + product_id = db.Column(db.Integer, db.ForeignKey('product.id'), index=True) + description = db.Column(db.String(255), nullable=False) + quantity = db.Column(db.Numeric(12, 2), nullable=False, default=1) + unit = db.Column(db.String(16), default='szt.') + unit_net = db.Column(db.Numeric(12, 2), nullable=False, default=0) + vat_rate = db.Column(db.Numeric(5, 2), nullable=False, default=23) + net_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) + vat_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) + gross_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) + + invoice = db.relationship('Invoice', backref=db.backref('lines', lazy='dynamic', cascade='all, delete-orphan')) + product = db.relationship('Product') diff --git a/app/models/company.py b/app/models/company.py new file mode 100644 index 0000000..89d15f1 --- /dev/null +++ b/app/models/company.py @@ -0,0 +1,29 @@ +from app.extensions import db +from app.models.base import TimestampMixin + + +class Company(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), nullable=False, unique=True) + tax_id = db.Column(db.String(32), index=True) + regon = db.Column(db.String(32), index=True, default='') + address = db.Column(db.String(255), default='') + bank_account = db.Column(db.String(64), default='') + is_active = db.Column(db.Boolean, default=True, nullable=False) + sync_enabled = db.Column(db.Boolean, default=False, nullable=False) + sync_interval_minutes = db.Column(db.Integer, default=60, nullable=False) + notification_enabled = db.Column(db.Boolean, default=True, nullable=False) + note = db.Column(db.Text) + + +class UserCompanyAccess(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True) + company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False, index=True) + access_level = db.Column(db.String(20), default='full', nullable=False) + + user = db.relationship('User', back_populates='company_access') + company = db.relationship('Company', back_populates='user_access') + + +Company.user_access = db.relationship('UserCompanyAccess', back_populates='company', cascade='all, delete-orphan') diff --git a/app/models/invoice.py b/app/models/invoice.py new file mode 100644 index 0000000..a606bb9 --- /dev/null +++ b/app/models/invoice.py @@ -0,0 +1,185 @@ +import enum + +from app.extensions import db +from app.models.base import TimestampMixin + + +invoice_tags = db.Table( + 'invoice_tags', + db.Column('invoice_id', db.Integer, db.ForeignKey('invoice.id'), primary_key=True), + db.Column('tag_id', db.Integer, db.ForeignKey('tag.id'), primary_key=True), +) + + +class InvoiceType(enum.Enum): + PURCHASE = 'purchase' + SALE = 'sale' + CORRECTION = 'correction' + + +class InvoiceStatus(enum.Enum): + NEW = 'new' + READ = 'read' + ACCOUNTED = 'accounted' + SENT = 'sent' + ARCHIVED = 'archived' + NEEDS_ATTENTION = 'needs_attention' + ERROR = 'error' + + + +INVOICE_TYPE_LABELS = { + InvoiceType.PURCHASE: 'Zakupowa', + InvoiceType.SALE: 'Sprzedażowa', + InvoiceType.CORRECTION: 'Korekta', +} + +INVOICE_STATUS_LABELS = { + InvoiceStatus.NEW: 'Nowa', + InvoiceStatus.READ: 'Odczytana', + InvoiceStatus.ACCOUNTED: 'Zaksięgowana', + InvoiceStatus.SENT: 'Wysłana', + InvoiceStatus.ARCHIVED: 'Archiwalna', + InvoiceStatus.NEEDS_ATTENTION: 'Do księgowania', + InvoiceStatus.ERROR: 'Błąd', +} + +ISSUED_STATUS_LABELS = { + 'draft': 'Robocza', + 'pending': 'Oczekuje wysyłki', + 'issued': 'Wysłana do KSeF', + 'received': 'Odebrana', + 'read': 'Odczytana', + 'accounted': 'Zaksięgowana', + 'queued': 'W kolejce', + 'error': 'Błąd', + 'sent': 'Wysłana', + 'needs_attention': 'Do księgowania', + 'issued_mock': 'Wysłana testowo', +} + + +class Invoice(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + company_id = db.Column(db.Integer, db.ForeignKey('company.id'), index=True) + ksef_number = db.Column(db.String(128), nullable=False, index=True) + invoice_number = db.Column(db.String(128), nullable=False, index=True) + + contractor_name = db.Column(db.String(255), nullable=False, index=True) + contractor_nip = db.Column(db.String(32), index=True) + contractor_regon = db.Column(db.String(32), index=True) + contractor_address = db.Column(db.String(512)) + + issue_date = db.Column(db.Date, nullable=False, index=True) + received_date = db.Column(db.Date, index=True) + fetched_at = db.Column(db.DateTime, index=True) + + net_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) + vat_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) + gross_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) + split_payment = db.Column(db.Boolean, default=False, nullable=False) + currency = db.Column(db.String(8), default='PLN') + seller_bank_account = db.Column(db.String(64), default='') + + invoice_type = db.Column(db.Enum(InvoiceType), nullable=False, default=InvoiceType.PURCHASE) + status = db.Column(db.Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.NEW) + + xml_path = db.Column(db.String(512)) + pdf_path = db.Column(db.String(512)) + html_preview = db.Column(db.Text) + internal_note = db.Column(db.Text) + source_hash = db.Column(db.String(128)) + read_at = db.Column(db.DateTime) + last_synced_at = db.Column(db.DateTime) + + external_metadata = db.Column(db.JSON, default=dict) + + is_unread = db.Column(db.Boolean, default=True, nullable=False) + pinned = db.Column(db.Boolean, default=False, nullable=False) + queue_accounting = db.Column(db.Boolean, default=False, nullable=False) + source = db.Column(db.String(32), default='ksef', nullable=False) + + customer_id = db.Column(db.Integer, db.ForeignKey('customer.id'), index=True) + + issued_to_ksef_at = db.Column(db.DateTime) + issued_status = db.Column(db.String(32), default='received', nullable=False) + + tags = db.relationship( + 'Tag', + secondary=invoice_tags, + lazy='joined', + backref=db.backref('invoices', lazy='dynamic'), + ) + sync_events = db.relationship( + 'SyncEvent', + backref='invoice', + lazy='dynamic', + cascade='all, delete-orphan', + ) + mail_deliveries = db.relationship( + 'MailDelivery', + backref='invoice', + lazy='dynamic', + cascade='all, delete-orphan', + ) + notifications = db.relationship( + 'NotificationLog', + backref='invoice', + lazy='dynamic', + cascade='all, delete-orphan', + ) + company = db.relationship('Company', backref=db.backref('invoices', lazy='dynamic')) + customer = db.relationship('Customer', backref=db.backref('invoices', lazy='dynamic')) + + __table_args__ = ( + db.UniqueConstraint('company_id', 'ksef_number', name='uq_invoice_company_ksef'), + ) + + @property + def month_key(self): + return f'{self.issue_date.year}-{self.issue_date.month:02d}' + + @property + def invoice_type_label(self): + return INVOICE_TYPE_LABELS.get(self.invoice_type, getattr(self.invoice_type, 'value', self.invoice_type)) + + @property + def status_label(self): + return INVOICE_STATUS_LABELS.get(self.status, getattr(self.status, 'value', self.status)) + + @property + def issued_status_label(self): + return ISSUED_STATUS_LABELS.get(self.issued_status, self.issued_status) + + +class Tag(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(64), unique=True, nullable=False) + color = db.Column(db.String(32), default='secondary') + + +class SyncEvent(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id'), nullable=False) + status = db.Column(db.String(32), nullable=False) + message = db.Column(db.Text) + source = db.Column(db.String(32), default='ksef') + + +class MailDelivery(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id'), nullable=False) + recipient = db.Column(db.String(255), nullable=False) + status = db.Column(db.String(32), default='queued') + subject = db.Column(db.String(255)) + error_message = db.Column(db.Text) + sent_at = db.Column(db.DateTime) + + +class NotificationLog(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + invoice_id = db.Column(db.Integer, db.ForeignKey('invoice.id')) + channel = db.Column(db.String(32), nullable=False) + status = db.Column(db.String(32), default='queued') + message = db.Column(db.Text) + sent_at = db.Column(db.DateTime) \ No newline at end of file diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000..5dc3373 --- /dev/null +++ b/app/models/notification.py @@ -0,0 +1 @@ +from app.models.invoice import NotificationLog diff --git a/app/models/setting.py b/app/models/setting.py new file mode 100644 index 0000000..ee75e6b --- /dev/null +++ b/app/models/setting.py @@ -0,0 +1,50 @@ +import base64 +import hashlib +from cryptography.fernet import Fernet, InvalidToken +from flask import current_app +from sqlalchemy.exc import OperationalError, ProgrammingError +from app.extensions import db +from app.models.base import TimestampMixin + + +class AppSetting(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String(128), unique=True, nullable=False, index=True) + value = db.Column(db.Text) + is_encrypted = db.Column(db.Boolean, default=False, nullable=False) + + @classmethod + def _cipher(cls): + secret = current_app.config.get('APP_MASTER_KEY', current_app.config.get('SECRET_KEY', 'dev')).encode('utf-8') + digest = hashlib.sha256(secret).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + @classmethod + def get(cls, key, default=None, decrypt=False): + try: + item = cls.query.filter_by(key=key).first() + if not item: + return default + if decrypt and item.is_encrypted and item.value: + try: + return cls._cipher().decrypt(item.value.encode('utf-8')).decode('utf-8') + except InvalidToken: + return default + return item.value if item.value is not None else default + except (OperationalError, ProgrammingError): + return default + + @classmethod + def set(cls, key, value, encrypt=False): + item = cls.query.filter_by(key=key).first() + if not item: + item = cls(key=key) + db.session.add(item) + item.is_encrypted = encrypt + if value is None: + item.value = None + elif encrypt: + item.value = cls._cipher().encrypt(str(value).encode('utf-8')).decode('utf-8') + else: + item.value = str(value) + return item diff --git a/app/models/sync_log.py b/app/models/sync_log.py new file mode 100644 index 0000000..0fed6f0 --- /dev/null +++ b/app/models/sync_log.py @@ -0,0 +1,18 @@ +from app.extensions import db +from app.models.base import TimestampMixin + + +class SyncLog(TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + company_id = db.Column(db.Integer, db.ForeignKey('company.id'), index=True) + sync_type = db.Column(db.String(32), default='started') + status = db.Column(db.String(32), default='started') + started_at = db.Column(db.DateTime, nullable=False) + finished_at = db.Column(db.DateTime) + processed = db.Column(db.Integer, default=0) + total = db.Column(db.Integer, default=0) + created = db.Column(db.Integer, default=0) + updated = db.Column(db.Integer, default=0) + errors = db.Column(db.Integer, default=0) + message = db.Column(db.Text) + company = db.relationship('Company', backref=db.backref('sync_logs', lazy='dynamic')) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..911f7da --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,40 @@ +from flask_login import UserMixin +from werkzeug.security import check_password_hash +from app.extensions import db, login_manager +from app.models.base import TimestampMixin + + +class User(UserMixin, TimestampMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + name = db.Column(db.String(255), nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + role = db.Column(db.String(50), default='operator', nullable=False) + theme_preference = db.Column(db.String(20), default='light', nullable=False) + is_blocked = db.Column(db.Boolean, default=False, nullable=False) + force_password_change = db.Column(db.Boolean, default=False, nullable=False) + last_login_at = db.Column(db.DateTime) + company_access = db.relationship('UserCompanyAccess', back_populates='user', cascade='all, delete-orphan') + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def companies(self): + return [item.company for item in self.company_access if item.company and item.company.is_active] + + def can_access_company(self, company_id): + return any(item.company_id == company_id for item in self.company_access) + + def company_access_level(self, company_id): + for item in self.company_access: + if item.company_id == company_id: + return item.access_level + return None + + def is_company_readonly(self, company_id): + return self.company_access_level(company_id) == 'readonly' or self.role == 'readonly' + + +@login_manager.user_loader +def load_user(user_id): + return db.session.get(User, int(user_id)) diff --git a/app/nfz/__init__.py b/app/nfz/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/nfz/routes.py b/app/nfz/routes.py new file mode 100644 index 0000000..411fc5b --- /dev/null +++ b/app/nfz/routes.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from flask import Blueprint, abort, flash, redirect, render_template, request, url_for +from flask_login import login_required + +from app.extensions import db +from app.forms.nfz import NFZ_BRANCH_CHOICES, NfzInvoiceForm +from app.models.catalog import Customer, InvoiceLine, Product +from app.models.invoice import Invoice, InvoiceStatus, InvoiceType +from app.services.audit_service import AuditService +from app.services.company_service import CompanyService +from app.services.invoice_service import InvoiceService +from app.services.ksef_service import KSeFService +from app.services.settings_service import SettingsService + +bp = Blueprint('nfz', __name__, url_prefix='/nfz') + +NFZ_NIP = '1070001057' +NFZ_BRANCH_MAP = dict(NFZ_BRANCH_CHOICES) + + +def _company(): + return CompanyService.get_current_company() + + +def _module_enabled(company_id: int | None) -> bool: + return bool(company_id) and SettingsService.get_effective('modules.nfz_enabled', 'false', company_id=company_id) == 'true' + + +def _invoice_or_404(invoice_id: int): + company = _company() + invoice = db.session.get(Invoice, invoice_id) + if not invoice or not company or invoice.company_id != company.id or invoice.source != 'nfz': + abort(404) + return invoice + + +def _prepare_form(form: NfzInvoiceForm, company): + customers = Customer.query.filter_by(company_id=company.id, is_active=True).order_by(Customer.name).all() + products = Product.query.filter_by(company_id=company.id, is_active=True).order_by(Product.name).all() + form.customer_id.choices = [(c.id, f'{c.name} ({c.tax_id})' if c.tax_id else c.name) for c in customers] + form.product_id.choices = [(p.id, f'{p.name} - {p.net_price} PLN') for p in products] + return customers, products + + +def _hydrate_form_from_invoice(form: NfzInvoiceForm, invoice: Invoice, *, duplicate: bool = False): + meta = (invoice.external_metadata or {}).get('nfz', {}) + line = invoice.lines.first() + form.customer_id.data = invoice.customer_id + form.product_id.data = line.product_id if line and line.product_id else None + form.quantity.data = line.quantity if line else Decimal('1') + form.unit_net.data = line.unit_net if line else invoice.net_amount + form.invoice_number.data = f'{invoice.invoice_number}/COPY' if duplicate else invoice.invoice_number + form.nfz_branch_id.data = meta.get('recipient_branch_id') + if meta.get('settlement_from'): + form.settlement_from.data = date.fromisoformat(meta['settlement_from']) + if meta.get('settlement_to'): + form.settlement_to.data = date.fromisoformat(meta['settlement_to']) + form.template_identifier.data = meta.get('template_identifier', '') + form.provider_identifier.data = meta.get('provider_identifier', '') + form.service_code.data = meta.get('service_code', '') + form.contract_number.data = meta.get('contract_number', '') + + +def _build_nfz_metadata(form: NfzInvoiceForm): + return { + 'nfz': { + 'recipient_nip': NFZ_NIP, + 'recipient_branch_id': form.nfz_branch_id.data, + 'recipient_branch_name': NFZ_BRANCH_MAP.get(form.nfz_branch_id.data, form.nfz_branch_id.data), + 'settlement_from': form.settlement_from.data.isoformat(), + 'settlement_to': form.settlement_to.data.isoformat(), + 'template_identifier': (form.template_identifier.data or '').strip(), + 'provider_identifier': form.provider_identifier.data.strip(), + 'service_code': form.service_code.data.strip(), + 'contract_number': form.contract_number.data.strip(), + 'nfz_schema': 'FA(3)', + 'required_fields': ['IDWew', 'P_6_Od', 'P_6_Do', 'identyfikator-swiadczeniodawcy', 'Indeks', 'NrUmowy'], + } + } + + +def _save_invoice_from_form(invoice: Invoice | None, form: NfzInvoiceForm, company, *, send_to_ksef: bool): + customer = db.session.get(Customer, form.customer_id.data) + product = db.session.get(Product, form.product_id.data) + qty = Decimal(str(form.quantity.data or 1)) + unit_net = Decimal(str(form.unit_net.data or product.net_price)) + net = (qty * unit_net).quantize(Decimal('0.01')) + vat = (net * (Decimal(str(product.vat_rate)) / Decimal('100'))).quantize(Decimal('0.01')) + gross = net + vat + number = form.invoice_number.data or (invoice.invoice_number if invoice else InvoiceService().next_sale_number(company.id, 'monthly')) + metadata = _build_nfz_metadata(form) + if invoice is None: + invoice = Invoice( + company_id=company.id, + customer_id=customer.id, + ksef_number=f'NFZ-PENDING/{number}', + invoice_number=number, + contractor_name=f'Narodowy Fundusz Zdrowia - {NFZ_BRANCH_MAP.get(form.nfz_branch_id.data, form.nfz_branch_id.data)}', + contractor_nip=NFZ_NIP, + issue_date=InvoiceService().today_date(), + received_date=InvoiceService().today_date(), + net_amount=net, + vat_amount=vat, + gross_amount=gross, + seller_bank_account=(company.bank_account or '').strip(), + invoice_type=InvoiceType.SALE, + status=InvoiceStatus.NEW, + source='nfz', + issued_status='draft' if not send_to_ksef else 'pending', + external_metadata=dict(metadata, seller_bank_account=(company.bank_account or '').strip()), + html_preview='', + ) + db.session.add(invoice) + db.session.flush() + else: + invoice.customer_id = customer.id + invoice.invoice_number = number + invoice.ksef_number = f'NFZ-PENDING/{number}' if not invoice.issued_to_ksef_at else invoice.ksef_number + invoice.contractor_name = f'Narodowy Fundusz Zdrowia - {NFZ_BRANCH_MAP.get(form.nfz_branch_id.data, form.nfz_branch_id.data)}' + invoice.contractor_nip = NFZ_NIP + invoice.net_amount = net + invoice.vat_amount = vat + invoice.gross_amount = gross + invoice.seller_bank_account = (company.bank_account or '').strip() + invoice.external_metadata = dict(metadata, seller_bank_account=invoice.seller_bank_account) + invoice.issued_status = 'draft' if not send_to_ksef else 'pending' + + line = invoice.lines.first() + if not line: + line = InvoiceLine(invoice_id=invoice.id) + db.session.add(line) + line.product_id = product.id + line.description = product.name + line.quantity = qty + line.unit = product.unit + line.unit_net = unit_net + line.vat_rate = product.vat_rate + line.net_amount = net + line.vat_amount = vat + line.gross_amount = gross + + payload = InvoiceService().build_ksef_payload(invoice) + InvoiceService().persist_issued_assets(invoice, xml_content=payload['xml_content']) + if send_to_ksef: + result = KSeFService(company_id=company.id).issue_invoice(payload) + invoice.ksef_number = result.get('ksef_number', invoice.ksef_number) + invoice.issued_status = result.get('status', 'issued') + invoice.issued_to_ksef_at = InvoiceService().utcnow() + InvoiceService().persist_issued_assets(invoice, xml_content=result.get('xml_content') or payload['xml_content']) + return invoice, result + return invoice, None + + +@bp.before_request +@login_required +def ensure_enabled(): + company = _company() + if not _module_enabled(company.id if company else None): + abort(404) + + +@bp.route('/', methods=['GET', 'POST']) +@login_required +def index(): + company = _company() + form = NfzInvoiceForm() + customers, products = _prepare_form(form, company) + drafts = ( + Invoice.query.filter_by(company_id=company.id, source='nfz') + .order_by(Invoice.created_at.desc()) + .limit(10) + .all() + ) + if request.method == 'GET': + duplicate_id = request.args.get('duplicate_id', type=int) + if duplicate_id: + _hydrate_form_from_invoice(form, _invoice_or_404(duplicate_id), duplicate=True) + else: + created_customer_id = request.args.get('created_customer_id', type=int) + created_product_id = request.args.get('created_product_id', type=int) + if created_customer_id and any(c.id == created_customer_id for c in customers): + form.customer_id.data = created_customer_id + elif customers and not form.customer_id.data: + form.customer_id.data = customers[0].id + if created_product_id and any(p.id == created_product_id for p in products): + selected = next((p for p in products if p.id == created_product_id), None) + form.product_id.data = created_product_id + form.unit_net.data = selected.net_price if selected else form.unit_net.data + elif products and not form.product_id.data: + form.product_id.data = products[0].id + form.unit_net.data = products[0].net_price + if not form.invoice_number.data: + form.invoice_number.data = InvoiceService().next_sale_number(company.id, 'monthly') + if form.validate_on_submit(): + if SettingsService.read_only_enabled(company_id=company.id): + flash('Tryb tylko do odczytu jest aktywny dla tej firmy.', 'warning') + return redirect(url_for('nfz.index')) + if form.settlement_to.data < form.settlement_from.data: + flash('Data końcowa okresu rozliczeniowego nie może być wcześniejsza niż data początkowa.', 'warning') + return render_template('nfz/index.html', form=form, drafts=drafts, company=company, spec_fields=spec_fields()) + invoice, result = _save_invoice_from_form(None, form, company, send_to_ksef=bool(form.submit.data)) + if result: + flash(result.get('message', 'Zapisano i wysłano fakturę NFZ do KSeF.'), 'success') + AuditService().log('send_nfz_invoice_to_ksef', 'invoice', invoice.id, invoice.ksef_number) + else: + flash('Zapisano roboczą fakturę NFZ.', 'success') + AuditService().log('draft_nfz_invoice', 'invoice', invoice.id, invoice.invoice_number) + db.session.commit() + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + return render_template('nfz/index.html', form=form, drafts=drafts, company=company, spec_fields=spec_fields()) + + +@bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit(invoice_id): + invoice = _invoice_or_404(invoice_id) + if InvoiceService.invoice_locked(invoice): + flash('Ta faktura została już wysłana do KSeF i nie można jej edytować.', 'warning') + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + company = _company() + form = NfzInvoiceForm() + _prepare_form(form, company) + if request.method == 'GET': + _hydrate_form_from_invoice(form, invoice) + created_customer_id = request.args.get('created_customer_id', type=int) + created_product_id = request.args.get('created_product_id', type=int) + customers = Customer.query.filter_by(company_id=company.id, is_active=True).order_by(Customer.name).all() + products = Product.query.filter_by(company_id=company.id, is_active=True).order_by(Product.name).all() + if created_customer_id and any(c.id == created_customer_id for c in customers): + form.customer_id.data = created_customer_id + if created_product_id and any(p.id == created_product_id for p in products): + selected = next((p for p in products if p.id == created_product_id), None) + form.product_id.data = created_product_id + if selected: + form.unit_net.data = selected.net_price + if form.validate_on_submit(): + if form.settlement_to.data < form.settlement_from.data: + flash('Data końcowa okresu rozliczeniowego nie może być wcześniejsza niż data początkowa.', 'warning') + return render_template('nfz/index.html', form=form, drafts=[], company=company, spec_fields=spec_fields(), editing_invoice=invoice) + invoice, result = _save_invoice_from_form(invoice, form, company, send_to_ksef=bool(form.submit.data)) + db.session.commit() + if result: + flash(result.get('message', 'Zapisano i wysłano fakturę NFZ do KSeF.'), 'success') + else: + flash('Zapisano zmiany w fakturze NFZ.', 'success') + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + return render_template('nfz/index.html', form=form, drafts=[], company=company, spec_fields=spec_fields(), editing_invoice=invoice) + + +@bp.post('//send-to-ksef') +@login_required +def send_to_ksef(invoice_id): + invoice = _invoice_or_404(invoice_id) + if InvoiceService.invoice_locked(invoice): + flash('Ta faktura została już wysłana do KSeF i jest zablokowana do edycji.', 'warning') + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + payload = InvoiceService().build_ksef_payload(invoice) + result = KSeFService(company_id=invoice.company_id).issue_invoice(payload) + invoice.ksef_number = result.get('ksef_number', invoice.ksef_number) + invoice.issued_status = result.get('status', 'issued') + invoice.issued_to_ksef_at = InvoiceService().utcnow() + invoice.external_metadata = dict(invoice.external_metadata or {}, ksef_send=result) + InvoiceService().persist_issued_assets(invoice, xml_content=result.get('xml_content') or payload['xml_content']) + db.session.commit() + flash(result.get('message', 'Wysłano fakturę NFZ do KSeF.'), 'success') + return redirect(url_for('invoices.detail', invoice_id=invoice.id)) + + +def spec_fields(): + return [ + ('IDWew', 'Identyfikator OW NFZ w Podmiot3/DaneIdentyfikacyjne.'), + ('P_6_Od / P_6_Do', 'Zakres dat okresu rozliczeniowego od i do.'), + ('identyfikator-szablonu', 'Id szablonu z komunikatu R_UMX, gdy jest wymagany.'), + ('identyfikator-swiadczeniodawcy', 'Kod świadczeniodawcy nadany w OW NFZ.'), + ('Indeks', 'Kod zakresu świadczeń / wyróżnik / kod świadczenia.'), + ('NrUmowy', 'Numer umowy NFZ, a dla aneksu także numer aneksu ugodowego.'), + ] diff --git a/app/notifications/__init__.py b/app/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/notifications/routes.py b/app/notifications/routes.py new file mode 100644 index 0000000..45c0f10 --- /dev/null +++ b/app/notifications/routes.py @@ -0,0 +1,12 @@ +from flask import Blueprint, render_template +from flask_login import login_required +from app.models.invoice import NotificationLog + +bp = Blueprint('notifications', __name__, url_prefix='/notifications') + + +@bp.route('/') +@login_required +def index(): + logs = NotificationLog.query.order_by(NotificationLog.created_at.desc()).limit(100).all() + return render_template('notifications/index.html', logs=logs) diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/repositories/invoice_repository.py b/app/repositories/invoice_repository.py new file mode 100644 index 0000000..c505c04 --- /dev/null +++ b/app/repositories/invoice_repository.py @@ -0,0 +1,108 @@ +from datetime import date, timedelta +from sqlalchemy import Integer, extract, or_, func, cast +from app.models.invoice import Invoice, InvoiceStatus, InvoiceType +from app.services.company_service import CompanyService + + +class InvoiceRepository: + def base_query(self, company_id=None): + if company_id is None: + company = CompanyService.get_current_company() + company_id = company.id if company else None + query = Invoice.query + if company_id: + query = query.filter(Invoice.company_id == company_id) + return query + + def incoming_query(self, company_id=None): + query = self.base_query(company_id) + return query.filter( + Invoice.invoice_type != InvoiceType.SALE, + ~Invoice.source.in_(['issued', 'nfz']), + ) + + def query_filtered(self, form_data, company_id=None): + query = self.incoming_query(company_id) + month = form_data.get('month') + year = form_data.get('year') + quick_filter = form_data.get('quick_filter') + min_amount = form_data.get('min_amount') + max_amount = form_data.get('max_amount') + if month: + query = query.filter(extract('month', Invoice.issue_date) == int(month)) + if year: + query = query.filter(extract('year', Invoice.issue_date) == int(year)) + if form_data.get('contractor'): + query = query.filter(Invoice.contractor_name.ilike(f"%{form_data['contractor']}%")) + if form_data.get('nip'): + query = query.filter(Invoice.contractor_nip.ilike(f"%{form_data['nip']}%")) + if form_data.get('invoice_type'): + query = query.filter(Invoice.invoice_type == InvoiceType(form_data['invoice_type'])) + if form_data.get('status'): + query = query.filter(Invoice.status == InvoiceStatus(form_data['status'])) + if min_amount: + query = query.filter(Invoice.gross_amount >= float(min_amount)) + if max_amount: + query = query.filter(Invoice.gross_amount <= float(max_amount)) + if form_data.get('search'): + term = f"%{form_data['search']}%" + query = query.filter(or_(Invoice.invoice_number.ilike(term), Invoice.ksef_number.ilike(term), Invoice.contractor_name.ilike(term), Invoice.contractor_nip.ilike(term))) + today = date.today() + if quick_filter == 'this_month': + query = query.filter(extract('month', Invoice.issue_date) == today.month, extract('year', Invoice.issue_date) == today.year) + elif quick_filter == 'previous_month': + prev = today.replace(day=1) - timedelta(days=1) + query = query.filter(extract('month', Invoice.issue_date) == prev.month, extract('year', Invoice.issue_date) == prev.year) + elif quick_filter == 'unread': + query = query.filter(Invoice.is_unread.is_(True)) + elif quick_filter == 'error': + query = query.filter(Invoice.status == InvoiceStatus.ERROR) + elif quick_filter == 'to_send': + query = query.filter(Invoice.status.in_([InvoiceStatus.NEW, InvoiceStatus.READ])) + return query.order_by(Invoice.issue_date.desc(), Invoice.id.desc()) + + def get_by_ksef_number(self, ksef_number, company_id=None): + return self.base_query(company_id).filter_by(ksef_number=ksef_number).first() + + def monthly_summary(self, company_id=None): + return self.base_query(company_id).with_entities( + extract('year', Invoice.issue_date).label('year'), + extract('month', Invoice.issue_date).label('month'), + func.count(Invoice.id).label('count'), + func.sum(Invoice.net_amount).label('net'), + func.sum(Invoice.vat_amount).label('vat'), + func.sum(Invoice.gross_amount).label('gross'), + ).group_by('year', 'month').order_by(extract('year', Invoice.issue_date).desc(), extract('month', Invoice.issue_date).desc()).all() + + def summary_query(self, company_id=None, *, period='month', search=None): + query = self.base_query(company_id) + if search: + like = f'%{search}%' + query = query.filter(or_( + Invoice.invoice_number.ilike(like), + Invoice.ksef_number.ilike(like), + Invoice.contractor_name.ilike(like), + Invoice.contractor_nip.ilike(like), + )) + year_expr = extract('year', Invoice.issue_date) + month_expr = extract('month', Invoice.issue_date) + quarter_expr = ((month_expr - 1) / 3 + 1) + columns = [ + year_expr.label('year'), + func.count(Invoice.id).label('count'), + func.sum(Invoice.net_amount).label('net'), + func.sum(Invoice.vat_amount).label('vat'), + func.sum(Invoice.gross_amount).label('gross'), + ] + group_by = [year_expr] + order_by = [year_expr.desc()] + if period == 'month': + columns.append(month_expr.label('month')) + group_by.append(month_expr) + order_by.append(month_expr.desc()) + elif period == 'quarter': + quarter_cast = cast(quarter_expr, Integer) + columns.append(quarter_cast.label('quarter')) + group_by.append(quarter_cast) + order_by.append(quarter_cast.desc()) + return query.with_entities(*columns).group_by(*group_by).order_by(*order_by).all() diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..8619bb9 --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,62 @@ +from datetime import datetime, timedelta +from apscheduler.schedulers.background import BackgroundScheduler +from flask import current_app + +scheduler = BackgroundScheduler(job_defaults={'coalesce': True, 'max_instances': 1}) + + +def init_scheduler(app): + if scheduler.running: + return + + def sync_job(): + with app.app_context(): + from app.models.company import Company + from app.models.sync_log import SyncLog + from app.services.sync_service import SyncService + current_app.logger.info('Scheduled sync checker started') + companies = Company.query.filter_by(is_active=True, sync_enabled=True).all() + for company in companies: + last_log = SyncLog.query.filter_by(company_id=company.id, status='finished').order_by(SyncLog.finished_at.desc()).first() + due = not last_log or not last_log.finished_at or (datetime.utcnow() - last_log.finished_at) >= timedelta(minutes=company.sync_interval_minutes) + if due: + SyncService(company).run_scheduled_sync() + + def refresh_dashboard_cache_job(): + with app.app_context(): + from app.models.company import Company + from app.models.invoice import Invoice + from app.services.health_service import HealthService + from app.services.redis_service import RedisService + from sqlalchemy import extract + from datetime import date + from decimal import Decimal + + today = date.today() + for company in Company.query.filter_by(is_active=True).all(): + base = Invoice.query.filter_by(company_id=company.id) + month_invoices = base.filter( + extract('month', Invoice.issue_date) == today.month, + extract('year', Invoice.issue_date) == today.year, + ).order_by(Invoice.issue_date.desc(), Invoice.id.desc()).all() + recent_ids = [invoice.id for invoice in base.order_by(Invoice.created_at.desc(), Invoice.id.desc()).limit(200).all()] + totals = { + 'net': str(sum(Decimal(invoice.net_amount) for invoice in month_invoices)), + 'vat': str(sum(Decimal(invoice.vat_amount) for invoice in month_invoices)), + 'gross': str(sum(Decimal(invoice.gross_amount) for invoice in month_invoices)), + } + RedisService.set_json( + f'dashboard.summary.company.{company.id}', + { + 'month_invoice_ids': [invoice.id for invoice in month_invoices], + 'unread': base.filter_by(is_unread=True).count(), + 'totals': totals, + 'recent_invoice_ids': recent_ids, + }, + ttl=300, + ) + HealthService().warm_cache(company.id) + + scheduler.add_job(sync_job, 'interval', minutes=1, id='ksef_sync', replace_existing=True) + scheduler.add_job(refresh_dashboard_cache_job, 'interval', minutes=5, id='dashboard_cache_refresh', replace_existing=True) + scheduler.start() diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..1686636 --- /dev/null +++ b/app/seed.py @@ -0,0 +1,61 @@ +from datetime import date, datetime, timedelta +from pathlib import Path +from werkzeug.security import generate_password_hash +from app.extensions import db +from app.models.invoice import Invoice, InvoiceStatus, InvoiceType, Tag +from app.models.setting import AppSetting +from app.models.user import User +from app.models.company import Company, UserCompanyAccess + + +def seed_data(): + company = Company.query.filter_by(name='Demo Sp. z o.o.').first() + if not company: + company = Company(name='Demo Sp. z o.o.', tax_id='5250000001', sync_enabled=True, sync_interval_minutes=60) + db.session.add(company) + db.session.flush() + if not User.query.filter_by(email='admin@example.com').first(): + user = User(email='admin@example.com', name='Admin', password_hash=generate_password_hash('admin123'), role='admin') + db.session.add(user) + db.session.flush() + db.session.add(UserCompanyAccess(user_id=user.id, company_id=company.id, access_level='full')) + AppSetting.set(f'company.{company.id}.ksef.last_sync_at', datetime.utcnow().isoformat()) + AppSetting.set(f'company.{company.id}.ksef.status', 'ready') + AppSetting.set(f'company.{company.id}.notify.enabled', 'true') + AppSetting.set(f'company.{company.id}.notify.min_amount', '1000') + AppSetting.set(f'company.{company.id}.ksef.base_url', 'https://api.ksef.mf.gov.pl/v2') + AppSetting.set(f'company.{company.id}.ksef.auth_mode', 'token') + AppSetting.set(f'company.{company.id}.ksef.mock_mode', 'true') + AppSetting.set(f'company.{company.id}.app.read_only_mode', 'false') + if Invoice.query.filter_by(company_id=company.id).count() == 0: + tag = Tag.query.filter_by(name='stały dostawca').first() or Tag(name='stały dostawca', color='primary') + db.session.add(tag) + db.session.flush() + archive_dir = Path('storage/archive/company_%s' % company.id) + archive_dir.mkdir(parents=True, exist_ok=True) + for idx in range(1, 13): + issued = date.today() - timedelta(days=idx * 7) + xml_path = archive_dir / f'FV_{idx:03d}.xml' + xml_path.write_text(f'FV/{idx:03d}/2026', encoding='utf-8') + invoice = Invoice( + company_id=company.id, + ksef_number=f'KSEF/C{company.id}/{2025 + (idx % 2)}/{1000 + idx}', + invoice_number=f'FV/{idx:03d}/2026', + contractor_name=f'Kontrahent {idx}', + contractor_nip=f'12345678{idx:02d}', + issue_date=issued, + received_date=issued + timedelta(days=1), + fetched_at=datetime.utcnow() - timedelta(days=idx), + net_amount=1000 * idx, + vat_amount=230 * idx, + gross_amount=1230 * idx, + invoice_type=InvoiceType.PURCHASE if idx % 2 else InvoiceType.SALE, + status=InvoiceStatus.NEW if idx % 3 else InvoiceStatus.ACCOUNTED, + currency='PLN', + xml_path=str(xml_path), + internal_note='Przykładowa faktura testowa.', + queue_accounting=idx % 2 == 0, + ) + invoice.tags.append(tag) + db.session.add(invoice) + db.session.commit() diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/audit_service.py b/app/services/audit_service.py new file mode 100644 index 0000000..78561a3 --- /dev/null +++ b/app/services/audit_service.py @@ -0,0 +1,19 @@ +from flask import request +from flask_login import current_user +from app.extensions import db +from app.models.audit_log import AuditLog + + +class AuditService: + def log(self, action, target_type, target_id=None, details=''): + entry = AuditLog( + user_id=current_user.id if getattr(current_user, 'is_authenticated', False) else None, + action=action, + target_type=target_type, + target_id=target_id, + remote_addr=request.remote_addr if request else None, + details=details, + ) + db.session.add(entry) + db.session.commit() + return entry diff --git a/app/services/backup_service.py b/app/services/backup_service.py new file mode 100644 index 0000000..a59df63 --- /dev/null +++ b/app/services/backup_service.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +import shutil + +from flask import current_app + + +class BackupService: + def create_backup(self): + target = Path(current_app.config['BACKUP_PATH']) / f'backup_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}' + target.mkdir(parents=True, exist_ok=True) + base_dir = Path(current_app.root_path).parent + for part in ['instance', 'storage/archive', 'storage/pdf']: + src = base_dir / part + if src.exists(): + shutil.copytree(src, target / Path(part).name, dirs_exist_ok=True) + archive = shutil.make_archive(str(target), 'zip', root_dir=target) + return archive + + def get_database_backup_meta(self) -> dict: + uri = current_app.config.get('SQLALCHEMY_DATABASE_URI', '') + backup_dir = Path(current_app.config['BACKUP_PATH']) + engine = 'unknown' + if '://' in uri: + engine = uri.split('://', 1)[0] + sqlite_supported = uri.startswith('sqlite:///') and not uri.endswith(':memory:') + sqlite_path = None + sqlite_exists = False + if sqlite_supported: + sqlite_path = Path(uri.replace('sqlite:///', '', 1)) + sqlite_exists = sqlite_path.exists() + return { + 'engine': engine, + 'backup_dir': str(backup_dir), + 'sqlite_supported': sqlite_supported, + 'sqlite_path': str(sqlite_path) if sqlite_path else None, + 'sqlite_exists': sqlite_exists, + 'notes': [ + 'Kopia z panelu działa plikowo dla SQLite.', + 'Dla PostgreSQL, MySQL i innych silników wymagany jest natywny dump bazy poza aplikacją.', + ], + } + + def create_database_backup(self) -> str: + target_dir = Path(current_app.config['BACKUP_PATH']) + target_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S') + uri = current_app.config.get('SQLALCHEMY_DATABASE_URI', '') + if uri.startswith('sqlite:///') and not uri.endswith(':memory:'): + source = Path(uri.replace('sqlite:///', '', 1)) + if not source.exists(): + raise FileNotFoundError(f'Plik bazy nie istnieje: {source}') + target = target_dir / f'db_backup_{timestamp}.sqlite3' + shutil.copy2(source, target) + return str(target) + target = target_dir / f'db_backup_{timestamp}.txt' + target.write_text( + """Automatyczna kopia DB dla bieżącego silnika nie jest obsługiwana plikowo. +W panelu admina kopia działa bezpośrednio tylko dla SQLite. +Wykonaj backup natywnym narzędziem bazy danych. +""", + encoding='utf-8', + ) + return str(target) diff --git a/app/services/ceidg_service.py b/app/services/ceidg_service.py new file mode 100644 index 0000000..e58ea1a --- /dev/null +++ b/app/services/ceidg_service.py @@ -0,0 +1,334 @@ +from __future__ import annotations +import json +import re +from typing import Any + +import requests + +from app.models.setting import AppSetting + + +class CeidgService: + DEFAULT_TIMEOUT = 12 + API_URLS = { + 'production': 'https://dane.biznes.gov.pl/api/ceidg/v3/firmy', + 'test': 'https://test-dane.biznes.gov.pl/api/ceidg/v3/firmy', + } + + @staticmethod + def _digits(value: str | None) -> str: + return re.sub(r'\D+', '', value or '') + + @staticmethod + def _clean_text(value: str | None) -> str: + if value is None: + return '' + value = re.sub(r'\s+', ' ', str(value)) + return value.strip(' ,;:-') + + @staticmethod + def _normalize_empty(value: Any) -> Any: + if value is None: + return '' + if isinstance(value, dict): + return {key: CeidgService._normalize_empty(item) for key, item in value.items()} + if isinstance(value, list): + return [CeidgService._normalize_empty(item) for item in value] + return value + + @classmethod + def get_environment(cls) -> str: + environment = AppSetting.get('ceidg.environment', 'production') + return environment if environment in cls.API_URLS else 'production' + + @classmethod + def get_api_url(cls, environment: str | None = None) -> str: + env = environment or cls.get_environment() + return cls.API_URLS.get(env, cls.API_URLS['production']) + + @staticmethod + def get_api_key() -> str: + return (AppSetting.get('ceidg.api_key', '', decrypt=True) or '').strip() + + @classmethod + def has_api_key(cls) -> bool: + return bool(cls.get_api_key()) + + @classmethod + def _headers(cls) -> dict[str, str]: + headers = {'Accept': 'application/json', 'User-Agent': 'KSeF Manager/1.0'} + api_key = cls.get_api_key() + if api_key: + token = api_key.strip() + headers['Authorization'] = token if token.lower().startswith('bearer ') else f'Bearer {token}' + return headers + + @staticmethod + def _safe_payload(response: requests.Response): + try: + return CeidgService._normalize_empty(response.json()) + except Exception: + return response.text[:1000] + + def fetch_company(self, identifier: str | None = None, *, nip: str | None = None) -> dict: + nip = self._digits(nip or identifier) + if len(nip) != 10: + return {'ok': False, 'message': 'Podaj poprawny 10-cyfrowy NIP.'} + + if not self.has_api_key(): + return { + 'ok': False, + 'message': 'Brak API KEY do CEIDG. Uzupełnij klucz w panelu admina.', + 'fallback': {'tax_id': nip}, + } + + environment = self.get_environment() + api_url = self.get_api_url(environment) + try: + response = requests.get(api_url, headers=self._headers(), params={'nip': nip}, timeout=self.DEFAULT_TIMEOUT) + if response.status_code in {401, 403}: + return { + 'ok': False, + 'message': 'CEIDG odrzucił token. Sprawdź klucz w panelu admina.', + 'error': response.text[:500], + 'debug': { + 'environment': environment, + 'url': api_url, + 'auth_prefix': 'Bearer' if self._headers().get('Authorization', '').startswith('Bearer ') else 'raw', + 'token_len': len(self.get_api_key()), + }, + 'fallback': {'tax_id': nip}, + } + if response.status_code == 404: + return {'ok': False, 'message': 'Nie znaleziono firmy o podanym NIP w CEIDG.', 'fallback': {'tax_id': nip}} + response.raise_for_status() + parsed = self._parse_payload(response.text, nip) + if parsed: + parsed['ok'] = True + parsed['source_url'] = api_url + parsed['environment'] = environment + parsed['message'] = 'Pobrano dane z API CEIDG.' + return parsed + return { + 'ok': False, + 'message': 'Nie znaleziono firmy w CEIDG. Podmiot może być zarejestrowany w KRS.', + 'sample': self._safe_payload(response), + 'fallback': {'tax_id': nip}, + } + except requests.exceptions.Timeout as exc: + error = f'Timeout: {exc}' + except requests.exceptions.RequestException as exc: + error = str(exc) + return { + 'ok': False, + 'message': 'Nie udało się pobrać danych z API CEIDG. Sprawdź konfigurację połączenia i API KEY w panelu admina.', + 'error': error, + 'fallback': {'tax_id': nip}, + } + + def diagnostics(self) -> dict: + environment = self.get_environment() + api_url = self.get_api_url(environment) + if not self.has_api_key(): + return { + 'status': 'error', + 'message': 'Brak API KEY do CEIDG.', + 'environment': environment, + 'url': api_url, + 'sample': {'error': 'missing_api_key'}, + 'technical_details': None, + 'token_length': 0, + } + try: + headers = self._headers() + response = requests.get(api_url, headers=headers, params={'nip': '3563457932'}, timeout=self.DEFAULT_TIMEOUT) + payload = self._safe_payload(response) + status = 'ok' if response.status_code not in {401, 403} and response.status_code < 500 else 'error' + technical_details = None + if response.status_code in {401, 403}: + technical_details = 'Autoryzacja odrzucona przez CEIDG.' + elif response.status_code in {400, 404, 422}: + technical_details = 'Połączenie działa, ale zapytanie diagnostyczne nie zwróciło danych testowych.' + return { + 'status': status, + 'message': f'HTTP {response.status_code}', + 'environment': environment, + 'url': api_url, + 'sample': payload, + 'technical_details': technical_details, + 'token_length': len(self.get_api_key()), + 'authorization_preview': headers.get('Authorization', '')[:20], + } + except Exception as exc: + message = f'Timeout: {exc}' if isinstance(exc, requests.exceptions.Timeout) else str(exc) + return { + 'status': 'error', + 'message': message, + 'environment': environment, + 'url': api_url, + 'sample': {'error': str(exc)}, + 'technical_details': None, + 'token_length': len(self.get_api_key()), + } + + def _parse_payload(self, payload: str, nip: str) -> dict | None: + try: + obj = json.loads(payload) + except Exception: + return None + found = self._walk_candidate(obj, nip) + if not found: + return None + found = self._normalize_empty(found) + found['tax_id'] = nip + found['name'] = self._clean_text(found.get('name')) + found['regon'] = self._digits(found.get('regon')) + found['address'] = self._clean_text(found.get('address')) + found['phone'] = self._clean_text(found.get('phone')) + found['email'] = self._clean_text(found.get('email')) + if not found['phone']: + found.pop('phone', None) + if not found['email']: + found.pop('email', None) + return found if found['name'] else None + + def _walk_candidate(self, candidate: Any, nip: str) -> dict | None: + if isinstance(candidate, dict): + candidate_nip = self._extract_nip(candidate) + if candidate_nip == nip: + return { + 'name': self._extract_name(candidate), + 'regon': self._extract_regon(candidate), + 'address': self._compose_address(candidate), + 'phone': self._extract_phone(candidate), + 'email': self._extract_email(candidate), + } + for value in candidate.values(): + nested = self._walk_candidate(value, nip) + if nested: + return nested + elif isinstance(candidate, list): + for item in candidate: + nested = self._walk_candidate(item, nip) + if nested: + return nested + return None + + def _extract_nip(self, candidate: dict) -> str: + owner = candidate.get('wlasciciel') or candidate.get('owner') or {} + values = [ + candidate.get('nip'), + candidate.get('Nip'), + candidate.get('taxId'), + candidate.get('tax_id'), + candidate.get('NIP'), + owner.get('nip') if isinstance(owner, dict) else '', + ] + for value in values: + digits = self._digits(value) + if digits: + return digits + return '' + + def _extract_name(self, candidate: dict) -> str: + owner = candidate.get('wlasciciel') or candidate.get('owner') or {} + values = [ + candidate.get('firma'), + candidate.get('nazwa'), + candidate.get('name'), + candidate.get('nazwaFirmy'), + candidate.get('przedsiebiorca'), + candidate.get('entrepreneurName'), + owner.get('nazwa') if isinstance(owner, dict) else '', + owner.get('nazwaSkrocona') if isinstance(owner, dict) else '', + owner.get('imieNazwisko') if isinstance(owner, dict) else '', + ] + for value in values: + cleaned = self._clean_text(value) + if cleaned: + return cleaned + return '' + + def _extract_regon(self, candidate: dict) -> str: + owner = candidate.get('wlasciciel') or candidate.get('owner') or {} + values = [ + candidate.get('regon'), + candidate.get('REGON'), + candidate.get('regon9'), + owner.get('regon') if isinstance(owner, dict) else '', + ] + for value in values: + digits = self._digits(value) + if digits: + return digits + return '' + + def _extract_phone(self, candidate: dict) -> str: + owner = candidate.get('wlasciciel') or candidate.get('owner') or {} + contact = candidate.get('kontakt') or candidate.get('contact') or {} + values = [ + candidate.get('telefon'), + candidate.get('telefonKontaktowy'), + candidate.get('phone'), + candidate.get('phoneNumber'), + contact.get('telefon') if isinstance(contact, dict) else '', + contact.get('phone') if isinstance(contact, dict) else '', + owner.get('telefon') if isinstance(owner, dict) else '', + owner.get('phone') if isinstance(owner, dict) else '', + ] + for value in values: + cleaned = self._clean_text(value) + if cleaned: + return cleaned + return '' + + def _extract_email(self, candidate: dict) -> str: + owner = candidate.get('wlasciciel') or candidate.get('owner') or {} + contact = candidate.get('kontakt') or candidate.get('contact') or {} + values = [ + candidate.get('email'), + candidate.get('adresEmail'), + candidate.get('emailAddress'), + contact.get('email') if isinstance(contact, dict) else '', + owner.get('email') if isinstance(owner, dict) else '', + ] + for value in values: + cleaned = self._clean_text(value) + if cleaned: + return cleaned + return '' + + def _compose_address(self, candidate: dict) -> str: + address = ( + candidate.get('adresDzialalnosci') + or candidate.get('adresGlownegoMiejscaWykonywaniaDzialalnosci') + or candidate.get('adres') + or candidate.get('address') + or {} + ) + if isinstance(address, str): + return self._clean_text(address) + if isinstance(address, dict): + street = self._clean_text(address.get('ulica') or address.get('street')) + building = self._clean_text(address.get('budynek') or address.get('numerNieruchomosci') or address.get('buildingNumber')) + apartment = self._clean_text(address.get('lokal') or address.get('numerLokalu') or address.get('apartmentNumber')) + postal_code = self._clean_text(address.get('kod') or address.get('kodPocztowy') or address.get('postalCode')) + city = self._clean_text(address.get('miasto') or address.get('miejscowosc') or address.get('city')) + parts = [] + street_part = ' '.join([part for part in [street, building] if part]).strip() + if apartment: + street_part = f'{street_part}/{apartment}' if street_part else apartment + if street_part: + parts.append(street_part) + city_part = ' '.join([part for part in [postal_code, city] if part]).strip() + if city_part: + parts.append(city_part) + return ', '.join(parts) + values = [ + candidate.get('ulica'), + candidate.get('numerNieruchomosci'), + candidate.get('numerLokalu'), + candidate.get('kodPocztowy'), + candidate.get('miejscowosc'), + ] + return ', '.join([self._clean_text(v) for v in values if self._clean_text(v)]) \ No newline at end of file diff --git a/app/services/company_service.py b/app/services/company_service.py new file mode 100644 index 0000000..78e4d23 --- /dev/null +++ b/app/services/company_service.py @@ -0,0 +1,56 @@ +from flask import session +from flask_login import current_user +from app.extensions import db +from app.models.company import Company, UserCompanyAccess + + +class CompanyService: + @staticmethod + def available_for_user(user=None): + user = user or current_user + if not getattr(user, 'is_authenticated', False): + return [] + return user.companies() + + @staticmethod + def get_current_company(user=None): + user = user or current_user + if not getattr(user, 'is_authenticated', False): + return None + company_id = session.get('current_company_id') + if company_id and user.can_access_company(company_id): + return db.session.get(Company, company_id) + companies = user.companies() + if companies: + session['current_company_id'] = companies[0].id + return companies[0] + return None + + @staticmethod + def switch_company(company_id, user=None): + user = user or current_user + if getattr(user, 'role', None) == 'admin' or user.can_access_company(company_id): + session['current_company_id'] = company_id + return True + return False + + @staticmethod + def create_company(name, tax_id='', regon='', address='', sync_enabled=False, sync_interval_minutes=60): + company = Company(name=name, tax_id=tax_id, regon=regon, address=address, sync_enabled=sync_enabled, sync_interval_minutes=sync_interval_minutes) + db.session.add(company) + db.session.commit() + return company + + @staticmethod + def assign_user(user, company, access_level='full', switch_after=False): + existing = UserCompanyAccess.query.filter_by(user_id=user.id, company_id=company.id).first() + if existing: + existing.access_level = access_level + else: + db.session.add(UserCompanyAccess(user_id=user.id, company_id=company.id, access_level=access_level)) + db.session.commit() + if switch_after: + try: + session['current_company_id'] = company.id + except RuntimeError: + pass diff --git a/app/services/health_service.py b/app/services/health_service.py new file mode 100644 index 0000000..6923af2 --- /dev/null +++ b/app/services/health_service.py @@ -0,0 +1,71 @@ +from datetime import datetime +from sqlalchemy import text +from app.extensions import db +from app.services.company_service import CompanyService +from app.services.ksef_service import KSeFService +from app.services.settings_service import SettingsService +from app.services.ceidg_service import CeidgService +from app.services.redis_service import RedisService + + +class HealthService: + CACHE_TTL = 300 + + def cache_key(self, company_id): + return f'health.status.company.{company_id or "global"}' + + def get_cached_status(self, company_id=None): + return RedisService.get_json(self.cache_key(company_id)) + + def warm_cache(self, company_id=None): + status = self.get_status(force_refresh=True, company_id=company_id) + RedisService.set_json(self.cache_key(company_id), status, ttl=self.CACHE_TTL) + return status + + def get_status(self, force_refresh: bool = False, company_id=None): + if not force_refresh: + cached = self.get_cached_status(company_id) + if cached: + return cached + + company = CompanyService.get_current_company() + if company_id is None and company: + company_id = company.id + redis_status, redis_details = RedisService.ping() + + status = { + 'timestamp': datetime.utcnow().isoformat(), + 'db': 'ok', + 'smtp': 'configured' if SettingsService.get_effective('mail.server', company_id=company_id) else 'not_configured', + 'redis': redis_status, + 'redis_details': redis_details, + 'ksef': 'unknown', + 'ceidg': 'unknown', + 'ksef_message': '', + 'ceidg_message': '', + } + + try: + db.session.execute(text('SELECT 1')) + except Exception: + status['db'] = 'error' + + try: + ping = KSeFService(company_id=company_id).ping() + status['ksef'] = ping.get('status', 'unknown') + status['ksef_message'] = ping.get('message', '') + except Exception as exc: + status['ksef'] = 'error' + status['ksef_message'] = str(exc) + + try: + ping = CeidgService().diagnostics() + status['ceidg'] = ping.get('status', 'unknown') + status['ceidg_message'] = ping.get('message', '') + except Exception as exc: + status['ceidg'] = 'error' + status['ceidg_message'] = str(exc) + + status['critical'] = status['db'] != 'ok' or status['ksef'] not in ['ok', 'mock'] or status['ceidg'] != 'ok' + RedisService.set_json(self.cache_key(company_id), status, ttl=self.CACHE_TTL) + return status diff --git a/app/services/invoice_party_service.py b/app/services/invoice_party_service.py new file mode 100644 index 0000000..764eb3e --- /dev/null +++ b/app/services/invoice_party_service.py @@ -0,0 +1,73 @@ +import re +import xml.etree.ElementTree as ET + + +class InvoicePartyService: + + @staticmethod + def extract_address_from_ksef_xml(xml_content: str): + result = { + 'street': None, + 'city': None, + 'postal_code': None, + 'country': None, + 'address': None, + } + + if not xml_content: + return result + + try: + root = ET.fromstring(xml_content) + except Exception: + return result + + def local_name(tag: str) -> str: + return tag.split('}', 1)[1] if '}' in tag else tag + + def clean(value): + return (value or '').strip() + + def find_text(names: list[str]): + wanted = {name.lower() for name in names} + for node in root.iter(): + if local_name(node.tag).lower() in wanted: + text = clean(node.text) + if text: + return text + return None + + street = find_text(['Ulica', 'AdresL1']) + house_no = find_text(['NrDomu']) + apartment_no = find_text(['NrLokalu']) + city = find_text(['Miejscowosc']) + postal_code = find_text(['KodPocztowy']) + country = find_text(['Kraj', 'KodKraju']) + adres_l2 = find_text(['AdresL2']) + + if adres_l2 and (not postal_code or not city): + match = re.match(r'^(\d{2}-\d{3})\s+(.+)$', clean(adres_l2)) + if match: + postal_code = postal_code or match.group(1) + city = city or clean(match.group(2)) + elif not city: + city = clean(adres_l2) + + street_line = clean(street) + if not street_line: + street_parts = [part for part in [street, house_no] if part] + street_line = ' '.join([p for p in street_parts if p]).strip() + if apartment_no: + street_line = f'{street_line}/{apartment_no}' if street_line else apartment_no + + address_parts = [part for part in [street_line, postal_code, city, country] if part] + + result.update({ + 'street': street_line or None, + 'city': city, + 'postal_code': postal_code, + 'country': country, + 'address': ', '.join(address_parts) if address_parts else None, + }) + + return result \ No newline at end of file diff --git a/app/services/invoice_service.py b/app/services/invoice_service.py new file mode 100644 index 0000000..35486a9 --- /dev/null +++ b/app/services/invoice_service.py @@ -0,0 +1,949 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import date, datetime +from decimal import Decimal +from pathlib import Path +from xml.sax.saxutils import escape +import xml.etree.ElementTree as ET + +from flask import current_app + +from sqlalchemy import or_ + +from app.extensions import db +from app.models.catalog import InvoiceLine +from app.models.invoice import Invoice, InvoiceStatus, InvoiceType, Tag, SyncEvent +from app.repositories.invoice_repository import InvoiceRepository +from app.services.company_service import CompanyService +from app.services.ksef_service import KSeFService +from app.services.settings_service import SettingsService + + +class InvoiceService: + PERIOD_LABELS = { + "month": "miesięczne", + "quarter": "kwartalne", + "year": "roczne", + } + + def __init__(self): + self.repo = InvoiceRepository() + + def upsert_from_ksef(self, document, company): + invoice = self.repo.get_by_ksef_number(document.ksef_number, company_id=company.id) + created = False + if not invoice: + invoice = Invoice(ksef_number=document.ksef_number, company_id=company.id) + db.session.add(invoice) + created = True + + invoice.invoice_number = document.invoice_number + invoice.contractor_name = document.contractor_name + invoice.contractor_nip = document.contractor_nip + invoice.issue_date = document.issue_date + invoice.received_date = document.received_date + invoice.fetched_at = document.fetched_at + invoice.net_amount = document.net_amount + invoice.vat_amount = document.vat_amount + invoice.gross_amount = document.gross_amount + invoice.invoice_type = InvoiceType(document.invoice_type) + invoice.last_synced_at = datetime.utcnow() + invoice.external_metadata = document.metadata + invoice.source_hash = KSeFService.calc_hash(document.xml_content) + invoice.xml_path = self._save_xml(company.id, invoice.ksef_number, document.xml_content) + metadata = dict(document.metadata or {}) + payment_details = self.extract_payment_details_from_xml(document.xml_content) + if payment_details.get('payment_form_code') and not metadata.get('payment_form_code'): + metadata['payment_form_code'] = payment_details['payment_form_code'] + if payment_details.get('payment_form_label') and not metadata.get('payment_form_label'): + metadata['payment_form_label'] = payment_details['payment_form_label'] + if payment_details.get('bank_account') and not metadata.get('seller_bank_account'): + metadata['seller_bank_account'] = payment_details['bank_account'] + if payment_details.get('bank_name') and not metadata.get('seller_bank_name'): + metadata['seller_bank_name'] = payment_details['bank_name'] + if payment_details.get('payment_due_date') and not metadata.get('payment_due_date'): + metadata['payment_due_date'] = payment_details['payment_due_date'] + invoice.external_metadata = metadata + invoice.seller_bank_account = payment_details.get('bank_account') or metadata.get('seller_bank_account') or invoice.seller_bank_account + invoice.contractor_address = ( + metadata.get("contractor_address") + or ", ".join([ + part for part in [ + metadata.get("contractor_street"), + metadata.get("contractor_postal_code"), + metadata.get("contractor_city"), + metadata.get("contractor_country"), + ] if part + ]) + or "" + ) + + db.session.flush() + + existing_lines = invoice.lines.count() if hasattr(invoice.lines, "count") else len(list(invoice.lines)) + if existing_lines == 0: + for line in self.extract_lines_from_xml(document.xml_content): + db.session.add( + InvoiceLine( + invoice_id=invoice.id, + description=line["description"], + quantity=line["quantity"], + unit=line["unit"], + unit_net=line["unit_net"], + vat_rate=line["vat_rate"], + net_amount=line["net_amount"], + vat_amount=line["vat_amount"], + gross_amount=line["gross_amount"], + ) + ) + + if not invoice.html_preview: + invoice.html_preview = self.render_invoice_html(invoice) + + db.session.flush() + db.session.add( + SyncEvent( + invoice_id=invoice.id, + status="created" if created else "updated", + message="Pobrano z KSeF", + ) + ) + SettingsService.set_many({"ksef.last_sync_at": datetime.utcnow().isoformat()}, company_id=company.id) + return invoice, created + + def _save_xml(self, company_id, ksef_number, xml_content): + base_path = SettingsService.storage_path("app.archive_path", current_app.config["ARCHIVE_PATH"]) / f"company_{company_id}" + base_path.mkdir(parents=True, exist_ok=True) + safe_name = ksef_number.replace("/", "_") + ".xml" + path = Path(base_path) / safe_name + path.write_text(xml_content, encoding="utf-8") + return str(path) + + def extract_lines_from_xml(self, xml_content): + def to_decimal(value, default='0'): + raw = str(value or '').strip() + if not raw: + return Decimal(default) + raw = raw.replace(' ', '').replace(',', '.') + try: + return Decimal(raw) + except Exception: + return Decimal(default) + + def to_vat_rate(value): + raw = str(value or '').strip().lower() + if not raw: + return Decimal('0') + raw = raw.replace('%', '').replace(' ', '').replace(',', '.') + try: + return Decimal(raw) + except Exception: + return Decimal('0') + + def looks_numeric(value): + raw = str(value or '').strip().replace(' ', '').replace(',', '.') + if not raw: + return False + try: + Decimal(raw) + return True + except Exception: + return False + + lines = [] + try: + root = ET.fromstring(xml_content) + namespace_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else '' + ns = {'fa': namespace_uri} if namespace_uri else {} + + row_path = './/fa:FaWiersz' if ns else './/FaWiersz' + text_path = lambda name: f'fa:{name}' if ns else name + + for row in root.findall(row_path, ns): + description = (row.findtext(text_path('P_7'), default='', namespaces=ns) or '').strip() + + p_8a = row.findtext(text_path('P_8A'), default='', namespaces=ns) + p_8b = row.findtext(text_path('P_8B'), default='', namespaces=ns) + + if looks_numeric(p_8a): + qty_raw = p_8a + unit_raw = p_8b or 'szt.' + else: + unit_raw = p_8a or 'szt.' + qty_raw = p_8b or '1' + + unit_net = ( + row.findtext(text_path('P_9A'), default='', namespaces=ns) + or row.findtext(text_path('P_9B'), default='', namespaces=ns) + or '0' + ) + + net = ( + row.findtext(text_path('P_11'), default='', namespaces=ns) + or row.findtext(text_path('P_11A'), default='', namespaces=ns) + or '0' + ) + + vat_rate = row.findtext(text_path('P_12'), default='0', namespaces=ns) + + vat = ( + row.findtext(text_path('P_12Z'), default='', namespaces=ns) + or row.findtext(text_path('P_11Vat'), default='', namespaces=ns) + or '0' + ) + + net_dec = to_decimal(net) + vat_dec = to_decimal(vat) + + lines.append({ + 'description': description, + 'quantity': to_decimal(qty_raw, '1'), + 'unit': (unit_raw or 'szt.').strip(), + 'unit_net': to_decimal(unit_net), + 'vat_rate': to_vat_rate(vat_rate), + 'net_amount': net_dec, + 'vat_amount': vat_dec, + 'gross_amount': net_dec + vat_dec, + }) + + except Exception as exc: + current_app.logger.warning(f'KSeF XML line parse error: {exc}') + + return lines + + def extract_payment_details_from_xml(self, xml_content): + details = { + "payment_form_code": "", + "payment_form_label": "", + "bank_account": "", + "bank_name": "", + "payment_due_date": "", + } + if not xml_content: + return details + try: + root = ET.fromstring(xml_content) + namespace_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else '' + ns = {'fa': namespace_uri} if namespace_uri else {} + + def find_text(path): + return (root.findtext(path, default='', namespaces=ns) or '').strip() + + form_code = find_text('.//fa:Platnosc/fa:FormaPlatnosci' if ns else './/Platnosc/FormaPlatnosci') + bank_account = find_text('.//fa:Platnosc/fa:RachunekBankowy/fa:NrRB' if ns else './/Platnosc/RachunekBankowy/NrRB') + bank_name = find_text('.//fa:Platnosc/fa:RachunekBankowy/fa:NazwaBanku' if ns else './/Platnosc/RachunekBankowy/NazwaBanku') + payment_due_date = find_text('.//fa:Platnosc/fa:TerminPlatnosci/fa:Termin' if ns else './/Platnosc/TerminPlatnosci/Termin') + + form_labels = { + '1': 'gotówka', + '2': 'karta', + '3': 'bon', + '4': 'czek', + '5': 'weksel', + '6': 'przelew', + '7': 'kompensata', + '8': 'pobranie', + '9': 'akredytywa', + '10': 'polecenie zapłaty', + '11': 'inny', + } + details.update({ + 'payment_form_code': form_code, + 'payment_form_label': form_labels.get(form_code, form_code), + 'bank_account': self._normalize_bank_account(bank_account), + 'bank_name': bank_name, + 'payment_due_date': payment_due_date, + }) + except Exception as exc: + current_app.logger.warning(f'KSeF XML payment parse error: {exc}') + return details + + @staticmethod + def _first_non_empty(*values, default=""): + + for value in values: + if value is None: + continue + if isinstance(value, str): + if value.strip(): + return value.strip() + continue + text = str(value).strip() + if text: + return text + return default + + @staticmethod + def _normalize_metadata_container(value): + return value if isinstance(value, dict) else {} + + def _get_external_metadata(self, invoice): + return self._normalize_metadata_container(getattr(invoice, "external_metadata", {}) or {}) + + def _get_ksef_metadata(self, invoice): + external_metadata = self._get_external_metadata(invoice) + return self._normalize_metadata_container(external_metadata.get("ksef", {}) or {}) + + def _resolve_purchase_seller_data(self, invoice): + external_metadata = self._get_external_metadata(invoice) + ksef_meta = self._get_ksef_metadata(invoice) + + seller_name = self._first_non_empty( + getattr(invoice, "contractor_name", ""), + external_metadata.get("contractor_name"), + ksef_meta.get("contractor_name"), + ) + seller_tax_id = self._first_non_empty( + getattr(invoice, "contractor_nip", ""), + external_metadata.get("contractor_nip"), + ksef_meta.get("contractor_nip"), + ) + + seller_street = self._first_non_empty( + external_metadata.get("contractor_street"), + ksef_meta.get("contractor_street"), + external_metadata.get("seller_street"), + ksef_meta.get("seller_street"), + ) + seller_city = self._first_non_empty( + external_metadata.get("contractor_city"), + ksef_meta.get("contractor_city"), + external_metadata.get("seller_city"), + ksef_meta.get("seller_city"), + ) + seller_postal_code = self._first_non_empty( + external_metadata.get("contractor_postal_code"), + ksef_meta.get("contractor_postal_code"), + external_metadata.get("seller_postal_code"), + ksef_meta.get("seller_postal_code"), + ) + seller_country = self._first_non_empty( + external_metadata.get("contractor_country"), + ksef_meta.get("contractor_country"), + external_metadata.get("seller_country"), + ksef_meta.get("seller_country"), + ) + + seller_address = self._first_non_empty( + external_metadata.get("contractor_address"), + ksef_meta.get("contractor_address"), + external_metadata.get("seller_address"), + ksef_meta.get("seller_address"), + getattr(invoice, "contractor_address", ""), + ) + + if not seller_address: + address_parts = [part for part in [seller_street, seller_postal_code, seller_city, seller_country] if part] + seller_address = ", ".join(address_parts) + + return { + "name": seller_name, + "tax_id": seller_tax_id, + "address": seller_address, + } + + def update_metadata(self, invoice, form): + invoice.status = InvoiceStatus(form.status.data) + invoice.internal_note = form.internal_note.data + invoice.pinned = bool(form.pinned.data) + invoice.queue_accounting = bool(form.queue_accounting.data) + invoice.is_unread = invoice.status == InvoiceStatus.NEW + requested_tags = [t.strip() for t in (form.tags.data or "").split(",") if t.strip()] + invoice.tags.clear() + for name in requested_tags: + tag = Tag.query.filter_by(name=name).first() + if not tag: + tag = Tag(name=name, color="primary") + db.session.add(tag) + invoice.tags.append(tag) + db.session.commit() + return invoice + + def mark_read(self, invoice): + if invoice.is_unread: + invoice.is_unread = False + invoice.status = InvoiceStatus.READ if invoice.status == InvoiceStatus.NEW else invoice.status + invoice.read_at = datetime.utcnow() + db.session.commit() + + def render_invoice_html(self, invoice): + def esc(value): + return str(value or "—").replace("&", "&").replace("<", "<").replace(">", ">") + + def money(value): + return f"{Decimal(value):,.2f} {invoice.currency or 'PLN'}".replace(",", " ").replace(".", ",") + + company_name_raw = invoice.company.name if invoice.company else "Twoja firma" + company_name = esc(company_name_raw) + company_tax_id = esc(getattr(invoice.company, "tax_id", "") if invoice.company else "") + company_address = esc(getattr(invoice.company, "address", "") if invoice.company else "") + + customer = getattr(invoice, "customer", None) + customer_name = esc(getattr(customer, "name", invoice.contractor_name)) + customer_tax_id = esc(getattr(customer, "tax_id", invoice.contractor_nip)) + customer_address = esc(getattr(customer, "address", "")) + customer_email = esc(getattr(customer, "email", "")) + + if invoice.invoice_type == InvoiceType.PURCHASE: + purchase_seller = self._resolve_purchase_seller_data(invoice) + + seller_name = esc(purchase_seller["name"]) + seller_tax_id = esc(purchase_seller["tax_id"]) + seller_address = esc(purchase_seller["address"]) + + buyer_name = company_name + buyer_tax_id = company_tax_id + buyer_address = company_address + buyer_email = "" + else: + seller_name = company_name + seller_tax_id = company_tax_id + seller_address = company_address + + buyer_name = customer_name + buyer_tax_id = customer_tax_id + buyer_address = customer_address + buyer_email = customer_email + + nfz_meta = (invoice.external_metadata or {}).get("nfz", {}) + invoice_kind = "FAKTURA NFZ" if invoice.source == "nfz" else "FAKTURA VAT" + + page_title = self._first_non_empty( + getattr(invoice, "invoice_number", ""), + getattr(invoice, "ksef_number", ""), + company_name_raw, + "Faktura", + ) + + lines = invoice.lines.order_by("id").all() if hasattr(invoice.lines, "order_by") else list(invoice.lines) + if not lines and invoice.xml_path: + try: + xml_content = Path(invoice.xml_path).read_text(encoding="utf-8") + extracted = self.extract_lines_from_xml(xml_content) + lines = [type("TmpLine", (), line) for line in extracted] + except Exception: + lines = [] + + lines_html = "".join( + [ + ( + f"" + f"{esc(line.description)}" + f"{esc(line.quantity)}" + f"{esc(line.unit)}" + f"{esc(line.vat_rate)}%" + f"{money(line.net_amount)}" + f"{money(line.gross_amount)}" + f"" + ) + for line in lines + ] + ) or ( + "" + "Brak pozycji na fakturze." + "" + ) + + nfz_rows = "" + if nfz_meta: + nfz_pairs = [ + ("Oddział NFZ (IDWew)", nfz_meta.get("recipient_branch_id")), + ("Nazwa oddziału", nfz_meta.get("recipient_branch_name")), + ("Okres rozliczeniowy od", nfz_meta.get("settlement_from")), + ("Okres rozliczeniowy do", nfz_meta.get("settlement_to")), + ("Identyfikator świadczeniodawcy", nfz_meta.get("provider_identifier")), + ("Kod zakresu / świadczenia", nfz_meta.get("service_code")), + ("Numer umowy / aneksu", nfz_meta.get("contract_number")), + ("Identyfikator szablonu", nfz_meta.get("template_identifier")), + ("Schemat", nfz_meta.get("nfz_schema", "FA(3)")), + ] + nfz_rows = "".join( + [ + f"{esc(label)}{esc(value)}" + for label, value in nfz_pairs + ] + ) + nfz_rows = ( + "
Dane NFZ
" + "" + "" + f"{nfz_rows}
Pole NFZWartość
" + ) + + buyer_email_html = f"
E-mail: {buyer_email}" if buyer_email not in {"", "—"} else "" + split_payment_html = "
Mechanizm podzielonej płatności
" if getattr(invoice, 'split_payment', False) else "" + payment_details = self.resolve_payment_details(invoice) + seller_bank_account = esc(self._resolve_seller_bank_account(invoice)) + payment_form_html = f"
Forma płatności: {esc(payment_details.get('payment_form_label'))}" if payment_details.get('payment_form_label') else '' + seller_bank_account_html = f"
Rachunek bankowy: {seller_bank_account}" if seller_bank_account not in {'', '—'} else '' + seller_bank_name_html = f"
Bank: {esc(payment_details.get('bank_name'))}" if payment_details.get('bank_name') else '' + + return ( + f"
" + f"
{invoice_kind}
" + f"" + f"" + f"" + f"
Numer faktury: {esc(invoice.invoice_number)}
Data wystawienia: {esc(invoice.issue_date)}
Waluta: {esc(invoice.currency or 'PLN')}
Numer KSeF: {esc(invoice.ksef_number)}
Status: {esc(invoice.issued_status)}
Typ źródła: {esc(invoice.source)}
" + f"" + f"" + f"" + f"
Sprzedawca
{seller_name}
NIP: {seller_tax_id}
Adres: {seller_address}{payment_form_html}{seller_bank_account_html}{seller_bank_name_html}
Nabywca
{buyer_name}
NIP: {buyer_tax_id}
Adres: {buyer_address}{buyer_email_html}
" + f"{nfz_rows}" + f"{split_payment_html}" + "
Pozycje faktury
" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + f"{lines_html}
PozycjaIlośćJMVATNettoBrutto
" + "" + f"" + f"" + f"" + "
Netto{money(invoice.net_amount)}
VAT{money(invoice.vat_amount)}
Razem brutto{money(invoice.gross_amount)}
" + f"
{'Dokument zawiera pola wymagane dla rozliczeń NFZ i został przygotowany do wysyłki w schemacie FA(3).' if nfz_meta else 'Dokument wygenerowany przez KSeF Manager.'}
" + "
" + ) + + def monthly_groups(self, company_id=None): + groups = [] + for row in self.repo.monthly_summary(company_id): + year = int(row.year) + month = int(row.month) + entries = self.repo.base_query(company_id).filter(Invoice.issue_date >= datetime(year, month, 1).date()) + if month == 12: + entries = entries.filter(Invoice.issue_date < datetime(year + 1, 1, 1).date()) + else: + entries = entries.filter(Invoice.issue_date < datetime(year, month + 1, 1).date()) + groups.append( + { + "key": f"{year}-{month:02d}", + "year": year, + "month": month, + "count": int(row.count or 0), + "net": row.net or Decimal("0"), + "vat": row.vat or Decimal("0"), + "gross": row.gross or Decimal("0"), + "entries": entries.order_by(Invoice.issue_date.desc(), Invoice.id.desc()).all(), + } + ) + return groups + + def grouped_summary(self, company_id=None, *, period="month", search=None): + rows = self.repo.summary_query(company_id, period=period, search=search) + grouped_entries = defaultdict(list) + entry_query = self.repo.base_query(company_id) + if search: + like = f"%{search}%" + entry_query = entry_query.filter( + or_( + Invoice.invoice_number.ilike(like), + Invoice.ksef_number.ilike(like), + Invoice.contractor_name.ilike(like), + Invoice.contractor_nip.ilike(like), + ) + ) + for invoice in entry_query.order_by(Invoice.issue_date.desc(), Invoice.id.desc()).all(): + if period == "year": + key = f"{invoice.issue_date.year}" + elif period == "quarter": + quarter = ((invoice.issue_date.month - 1) // 3) + 1 + key = f"{invoice.issue_date.year}-Q{quarter}" + else: + key = f"{invoice.issue_date.year}-{invoice.issue_date.month:02d}" + grouped_entries[key].append(invoice) + groups = [] + for row in rows: + year = int(row.year) + month = int(getattr(row, "month", 0) or 0) + quarter = int(getattr(row, "quarter", 0) or 0) + if period == "year": + key = f"{year}" + label = f"Rok {year}" + elif period == "quarter": + key = f"{year}-Q{quarter}" + label = f"Q{quarter} {year}" + else: + key = f"{year}-{month:02d}" + label = f"{year}-{month:02d}" + groups.append( + { + "key": key, + "label": label, + "year": year, + "month": month, + "quarter": quarter, + "count": int(row.count or 0), + "net": row.net or Decimal("0"), + "vat": row.vat or Decimal("0"), + "gross": row.gross or Decimal("0"), + "entries": grouped_entries.get(key, []), + } + ) + return groups + + def comparative_stats(self, company_id=None, *, search=None): + rows = self.repo.summary_query(company_id, period="year", search=search) + stats = [] + previous = None + for row in rows: + year = int(row.year) + gross = row.gross or Decimal("0") + net = row.net or Decimal("0") + count = int(row.count or 0) + delta = None + if previous is not None: + delta = gross - previous["gross"] + stats.append({"year": year, "gross": gross, "net": net, "count": count, "delta": delta}) + previous = {"gross": gross} + return stats + + def next_sale_number(self, company_id, template="monthly"): + today = self.today_date() + query = Invoice.query.filter(Invoice.company_id == company_id, Invoice.invoice_type == InvoiceType.SALE) + if template == "yearly": + query = query.filter(Invoice.issue_date >= date(today.year, 1, 1)) + prefix = f"FV/{today.year}/" + elif template == "custom": + prefix = f"FV/{today.year}/{today.month:02d}/" + query = query.filter(Invoice.issue_date >= date(today.year, today.month, 1)) + if today.month == 12: + query = query.filter(Invoice.issue_date < date(today.year + 1, 1, 1)) + else: + query = query.filter(Invoice.issue_date < date(today.year, today.month + 1, 1)) + else: + prefix = f"FV/{today.year}/{today.month:02d}/" + query = query.filter(Invoice.issue_date >= date(today.year, today.month, 1)) + if today.month == 12: + query = query.filter(Invoice.issue_date < date(today.year + 1, 1, 1)) + else: + query = query.filter(Invoice.issue_date < date(today.year, today.month + 1, 1)) + next_no = query.count() + 1 + return f"{prefix}{next_no:04d}" + + def build_ksef_payload(self, invoice): + lines = invoice.lines.order_by("id").all() if hasattr(invoice.lines, "order_by") else list(invoice.lines) + nfz_meta = (invoice.external_metadata or {}).get("nfz", {}) + xml_content = self.render_structured_xml(invoice, lines=lines, nfz_meta=nfz_meta) + payload = { + "invoiceNumber": invoice.invoice_number, + "invoiceType": invoice.invoice_type.value if hasattr(invoice.invoice_type, "value") else str(invoice.invoice_type), + "schemaVersion": nfz_meta.get("nfz_schema", "FA(3)"), + "customer": { + "name": invoice.contractor_name, + "taxId": invoice.contractor_nip, + }, + "lines": [ + { + "name": line.description, + "qty": float(line.quantity), + "unit": line.unit, + "unitNet": float(line.unit_net), + "vatRate": float(line.vat_rate), + "netAmount": float(line.net_amount), + "grossAmount": float(line.gross_amount), + } + for line in lines + ], + "metadata": { + "source": invoice.source, + "companyId": invoice.company_id, + "issueDate": invoice.issue_date.isoformat() if invoice.issue_date else "", + "currency": invoice.currency, + "nfz": nfz_meta, + "split_payment": bool(getattr(invoice, 'split_payment', False)), + }, + "xml_content": xml_content, + } + if nfz_meta: + payload["customer"]["internalBranchId"] = nfz_meta.get("recipient_branch_id", "") + return payload + + @staticmethod + def _xml_decimal(value, places=2): + quant = Decimal('1').scaleb(-places) + return format(Decimal(value or 0).quantize(quant), 'f') + + @staticmethod + def _split_address_lines(address): + raw = (address or '').strip() + if not raw: + return '', '' + parts = [part.strip() for part in raw.replace('\n', ',').split(',') if part.strip()] + if len(parts) <= 1: + return raw[:512], '' + line1 = ', '.join(parts[:2])[:512] + line2 = ', '.join(parts[2:])[:512] + return line1, line2 + + + @staticmethod + def _normalize_bank_account(value): + raw = ''.join(str(value or '').split()) + return raw + + def resolve_payment_details(self, invoice): + external_metadata = self._normalize_metadata_container(getattr(invoice, 'external_metadata', {}) or {}) + details = { + 'payment_form_code': self._first_non_empty(external_metadata.get('payment_form_code')), + 'payment_form_label': self._first_non_empty(external_metadata.get('payment_form_label')), + 'bank_account': self._normalize_bank_account( + self._first_non_empty( + getattr(invoice, 'seller_bank_account', ''), + external_metadata.get('seller_bank_account'), + ) + ), + 'bank_name': self._first_non_empty(external_metadata.get('seller_bank_name')), + 'payment_due_date': self._first_non_empty(external_metadata.get('payment_due_date')), + } + if getattr(invoice, 'xml_path', None) and (not details['bank_account'] or not details['payment_form_code'] or not details['bank_name'] or not details['payment_due_date']): + try: + xml_content = Path(invoice.xml_path).read_text(encoding='utf-8') + parsed = self.extract_payment_details_from_xml(xml_content) + for key, value in parsed.items(): + if value and not details.get(key): + details[key] = value + except Exception: + pass + return details + + def _resolve_seller_bank_account(self, invoice): + details = self.resolve_payment_details(invoice) + account = self._normalize_bank_account(details.get('bank_account', '')) + if account: + return account + if getattr(invoice, 'invoice_type', None) == InvoiceType.PURCHASE: + return '' + company = getattr(invoice, 'company', None) + return self._normalize_bank_account(getattr(company, 'bank_account', '') if company else '') + + @staticmethod + def _safe_tax_id(value): + digits = ''.join(ch for ch in str(value or '') if ch.isdigit()) + return digits + + def _append_xml_text(self, parent, tag, value): + if value is None: + return None + text = str(value).strip() + if not text: + return None + node = ET.SubElement(parent, tag) + node.text = text + return node + + def _append_address_node(self, parent, address, *, country_code='PL'): + line1, line2 = self._split_address_lines(address) + if not line1 and not line2: + return None + node = ET.SubElement(parent, 'Adres') + ET.SubElement(node, 'KodKraju').text = country_code or 'PL' + if line1: + ET.SubElement(node, 'AdresL1').text = line1 + if line2: + ET.SubElement(node, 'AdresL2').text = line2 + return node + + + def _append_party_section(self, root, tag_name, *, name, tax_id, address, country_code='PL'): + party = ET.SubElement(root, tag_name) + ident = ET.SubElement(party, 'DaneIdentyfikacyjne') + tax_digits = self._safe_tax_id(tax_id) + if tax_digits: + ET.SubElement(ident, 'NIP').text = tax_digits + else: + ET.SubElement(ident, 'BrakID').text = '1' + ET.SubElement(ident, 'Nazwa').text = (name or 'Brak nazwy').strip() + self._append_address_node(party, address, country_code=country_code) + return party + + def _append_tax_summary(self, fa_node, lines): + vat_groups = { + '23': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_1', 'p14': 'P_14_1'}, + '22': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_1', 'p14': 'P_14_1'}, + '8': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_2', 'p14': 'P_14_2'}, + '7': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_2', 'p14': 'P_14_2'}, + '5': {'net': Decimal('0'), 'vat': Decimal('0'), 'p13': 'P_13_3', 'p14': 'P_14_3'}, + } + for line in lines: + rate = Decimal(line.vat_rate or 0).normalize() + rate_key = format(rate, 'f').rstrip('0').rstrip('.') if '.' in format(rate, 'f') else format(rate, 'f') + group = vat_groups.get(rate_key) + if not group: + group = vat_groups.get('23') if Decimal(line.vat_amount or 0) > 0 else None + if not group: + continue + group['net'] += Decimal(line.net_amount or 0) + group['vat'] += Decimal(line.vat_amount or 0) + + for cfg in vat_groups.values(): + if cfg['net']: + ET.SubElement(fa_node, cfg['p13']).text = self._xml_decimal(cfg['net']) + ET.SubElement(fa_node, cfg['p14']).text = self._xml_decimal(cfg['vat']) + + def _append_adnotations(self, fa_node, *, split_payment=False): + adnotacje = ET.SubElement(fa_node, 'Adnotacje') + ET.SubElement(adnotacje, 'P_16').text = '2' + ET.SubElement(adnotacje, 'P_17').text = '2' + ET.SubElement(adnotacje, 'P_18').text = '2' + ET.SubElement(adnotacje, 'P_18A').text = '1' if split_payment else '2' + zw = ET.SubElement(adnotacje, 'Zwolnienie') + ET.SubElement(zw, 'P_19N').text = '1' + ET.SubElement(adnotacje, 'P_23').text = '2' + return adnotacje + + + def _append_payment_details(self, fa_node, invoice): + bank_account = self._resolve_seller_bank_account(invoice) + if not bank_account: + return None + external_metadata = self._normalize_metadata_container(getattr(invoice, 'external_metadata', {}) or {}) + bank_name = self._first_non_empty(external_metadata.get('seller_bank_name')) + platnosc = ET.SubElement(fa_node, 'Platnosc') + ET.SubElement(platnosc, 'FormaPlatnosci').text = '6' + rachunek = ET.SubElement(platnosc, 'RachunekBankowy') + ET.SubElement(rachunek, 'NrRB').text = bank_account + if bank_name: + ET.SubElement(rachunek, 'NazwaBanku').text = bank_name + return platnosc + + def _append_nfz_extra_description(self, fa_node, nfz_meta): + mapping = [ + ('IDWew', nfz_meta.get('recipient_branch_id', '')), + ('P_6_Od', nfz_meta.get('settlement_from', '')), + ('P_6_Do', nfz_meta.get('settlement_to', '')), + ('identyfikator-swiadczeniodawcy', nfz_meta.get('provider_identifier', '')), + ('Indeks', nfz_meta.get('service_code', '')), + ('NrUmowy', nfz_meta.get('contract_number', '')), + ('identyfikator-szablonu', nfz_meta.get('template_identifier', '')), + ] + for key, value in mapping: + if not str(value or '').strip(): + continue + node = ET.SubElement(fa_node, 'DodatkowyOpis') + ET.SubElement(node, 'Klucz').text = str(key) + ET.SubElement(node, 'Wartosc').text = str(value) + + def render_structured_xml(self, invoice, *, lines=None, nfz_meta=None): + lines = lines if lines is not None else (invoice.lines.order_by('id').all() if hasattr(invoice.lines, 'order_by') else list(invoice.lines)) + nfz_meta = nfz_meta or (invoice.external_metadata or {}).get('nfz', {}) + seller = invoice.company or CompanyService.get_current_company() + issue_date = invoice.issue_date.isoformat() if invoice.issue_date else self.today_date().isoformat() + created_at = self.utcnow().replace(microsecond=0).isoformat() + 'Z' + currency = (invoice.currency or 'PLN').strip() or 'PLN' + schema_code = str(nfz_meta.get('nfz_schema', 'FA(3)') or 'FA(3)') + schema_ns = 'http://crd.gov.pl/wzor/2025/06/25/13775/' if schema_code == 'FA(3)' else f'https://ksef.mf.gov.pl/schemat/faktura/{schema_code}' + + ET.register_namespace('', schema_ns) + root = ET.Element('Faktura', {'xmlns': schema_ns}) + + naglowek = ET.SubElement(root, 'Naglowek') + kod_formularza = ET.SubElement(naglowek, 'KodFormularza', {'kodSystemowy': 'FA (3)', 'wersjaSchemy': '1-0E'}) + kod_formularza.text = 'FA' + ET.SubElement(naglowek, 'WariantFormularza').text = '3' + ET.SubElement(naglowek, 'DataWytworzeniaFa').text = created_at + ET.SubElement(naglowek, 'SystemInfo').text = 'KSeF Manager' + + self._append_party_section( + root, + 'Podmiot1', + name=getattr(seller, 'name', '') or '', + tax_id=getattr(seller, 'tax_id', '') or '', + address=getattr(seller, 'address', '') or '', + ) + self._append_party_section( + root, + 'Podmiot2', + name=invoice.contractor_name or '', + tax_id=invoice.contractor_nip or '', + address=getattr(invoice, 'contractor_address', '') or '', + ) + + if nfz_meta.get('recipient_branch_id'): + podmiot3 = ET.SubElement(root, 'Podmiot3') + ident = ET.SubElement(podmiot3, 'DaneIdentyfikacyjne') + ET.SubElement(ident, 'IDWew').text = str(nfz_meta.get('recipient_branch_id')) + ET.SubElement(podmiot3, 'Rola').text = '7' + + fa = ET.SubElement(root, 'Fa') + ET.SubElement(fa, 'KodWaluty').text = currency + ET.SubElement(fa, 'P_1').text = issue_date + self._append_xml_text(fa, 'P_1M', nfz_meta.get('issue_place')) + ET.SubElement(fa, 'P_2').text = (invoice.invoice_number or '').strip() + ET.SubElement(fa, 'P_6').text = issue_date + self._append_xml_text(fa, 'P_6_Od', nfz_meta.get('settlement_from')) + self._append_xml_text(fa, 'P_6_Do', nfz_meta.get('settlement_to')) + + self._append_tax_summary(fa, lines) + ET.SubElement(fa, 'P_15').text = self._xml_decimal(invoice.gross_amount) + self._append_adnotations(fa, split_payment=bool(getattr(invoice, 'split_payment', False))) + self._append_payment_details(fa, invoice) + self._append_nfz_extra_description(fa, nfz_meta) + + for idx, line in enumerate(lines, start=1): + row = ET.SubElement(fa, 'FaWiersz') + ET.SubElement(row, 'NrWierszaFa').text = str(idx) + if nfz_meta.get('service_date'): + ET.SubElement(row, 'P_6A').text = str(nfz_meta.get('service_date')) + ET.SubElement(row, 'P_7').text = str(line.description or '') + ET.SubElement(row, 'P_8A').text = str(line.unit or 'szt.') + ET.SubElement(row, 'P_8B').text = self._xml_decimal(line.quantity, places=6).rstrip('0').rstrip('.') or '0' + ET.SubElement(row, 'P_9A').text = self._xml_decimal(line.unit_net, places=8).rstrip('0').rstrip('.') or '0' + ET.SubElement(row, 'P_11').text = self._xml_decimal(line.net_amount) + ET.SubElement(row, 'P_12').text = self._xml_decimal(line.vat_rate, places=2).rstrip('0').rstrip('.') or '0' + if Decimal(line.vat_amount or 0): + ET.SubElement(row, 'P_12Z').text = self._xml_decimal(line.vat_amount) + + stopka_parts = [] + if getattr(invoice, 'split_payment', False): + stopka_parts.append('Mechanizm podzielonej płatności.') + seller_bank_account = self._resolve_seller_bank_account(invoice) + if seller_bank_account: + stopka_parts.append(f'Rachunek bankowy: {seller_bank_account}') + if nfz_meta.get('contract_number'): + stopka_parts.append(f"NFZ umowa: {nfz_meta.get('contract_number')}") + if stopka_parts: + stopka = ET.SubElement(root, 'Stopka') + info = ET.SubElement(stopka, 'Informacje') + ET.SubElement(info, 'StopkaFaktury').text = ' '.join(stopka_parts) + + return ET.tostring(root, encoding='utf-8', xml_declaration=True).decode('utf-8') + + def persist_issued_assets(self, invoice, xml_content=None): + db.session.flush() + invoice.html_preview = self.render_invoice_html(invoice) + xml_content = xml_content or self.build_ksef_payload(invoice).get("xml_content") + if xml_content: + invoice.source_hash = KSeFService.calc_hash(xml_content) + invoice.xml_path = self._save_xml(invoice.company_id, invoice.ksef_number or invoice.invoice_number, xml_content) + PdfService = __import__("app.services.pdf_service", fromlist=["PdfService"]).PdfService + PdfService().render_invoice_pdf(invoice) + return xml_content + + @staticmethod + def invoice_locked(invoice): + return bool( + getattr(invoice, "issued_to_ksef_at", None) + or getattr(invoice, "issued_status", "") in {"issued", "issued_mock"} + ) + + @staticmethod + def today_date(): + return date.today() + + @staticmethod + def utcnow(): + return datetime.utcnow() + + @staticmethod + def period_title(period): + return InvoiceService.PERIOD_LABELS.get(period, InvoiceService.PERIOD_LABELS["month"]) \ No newline at end of file diff --git a/app/services/ksef_service.py b/app/services/ksef_service.py new file mode 100644 index 0000000..934bfcf --- /dev/null +++ b/app/services/ksef_service.py @@ -0,0 +1,1279 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal +import base64 +import hashlib +import re +import time +import xml.etree.ElementTree as ET +from typing import Any + +import requests + +from app.services.settings_service import SettingsService + +try: + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization, padding as sym_padding + from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +except Exception: # pragma: no cover + x509 = None + hashes = None + serialization = None + sym_padding = None + Cipher = None + algorithms = None + modes = None + padding = None + + +@dataclass +class KSeFDocument: + ksef_number: str + invoice_number: str + contractor_name: str + contractor_nip: str + issue_date: date + received_date: date + fetched_at: datetime + net_amount: Decimal + vat_amount: Decimal + gross_amount: Decimal + invoice_type: str + xml_content: str + metadata: dict + + +class KSeFAdapter: + def list_documents(self, since: datetime | None = None): + raise NotImplementedError + + def issue_invoice(self, payload: dict): + raise NotImplementedError + + def ping(self): + return {"status": "unknown"} + + def diagnostics(self): + return self.ping() + + +class MockKSeFAdapter(KSeFAdapter): + def __init__(self, company_id=None): + self.company_id = company_id or 0 + + def list_documents(self, since=None): + base_date = datetime.utcnow() + docs = [] + for i in range(1, 6): + issue = (base_date - timedelta(days=i * 3)).date() + net = Decimal(1000 + i * 250) + vat = (net * Decimal("0.23")).quantize(Decimal("0.01")) + gross = net + vat + seq = 10000 + i + docs.append( + KSeFDocument( + ksef_number=f"KSEF/MOCK/C{self.company_id}/{issue.year}/{seq}", + invoice_number=f"FV/{self.company_id}/{i:03d}/{issue.year}", + contractor_name=f"Firma {self.company_id}-{i}", + contractor_nip=f"525{self.company_id:02d}000{i:02d}", + issue_date=issue, + received_date=issue + timedelta(days=1), + fetched_at=base_date - timedelta(hours=i), + net_amount=net, + vat_amount=vat, + gross_amount=gross, + invoice_type="purchase" if i % 2 else "sale", + xml_content=( + f"{self.company_id}" + f"KSEF/MOCK/C{self.company_id}/{issue.year}/{seq}" + ), + metadata={"source": "mock", "sequence": i, "company_id": self.company_id}, + ) + ) + if since: + docs = [d for d in docs if d.fetched_at >= since] + return docs + + def issue_invoice(self, payload: dict): + now = datetime.utcnow() + seq = int(now.timestamp()) + return { + "ksef_number": f"KSEF/MOCK/ISSUED/C{self.company_id}/{now.year}/{seq}", + "status": "issued_mock", + "message": "Tryb mock: dokument nie został wysłany do produkcyjnego KSeF, tylko zasymulowany lokalnie.", + "xml_content": payload.get("xml_content", ""), + } + + def ping(self): + return { + "status": "mock", + "message": "Tryb mock aktywny - połączenie z prawdziwym API nie jest wykonywane.", + } + + def diagnostics(self): + ping = self.ping() + ping["base_url"] = "mock://ksef" + ping["sample"] = { + "status": "mock", + "documentExample": { + "invoiceNumber": f"FV/{self.company_id}/001/{datetime.utcnow().year}", + "ksefNumber": f"KSEF/MOCK/C{self.company_id}/{datetime.utcnow().year}/10001", + }, + } + return ping + + +class RequestsKSeFAdapter(KSeFAdapter): + ENVIRONMENT_URLS = { + 'prod': 'https://api.ksef.mf.gov.pl/v2', + 'test': 'https://api-test.ksef.mf.gov.pl/v2', + 'demo': 'https://api-demo.ksef.mf.gov.pl/v2', + } + ACCESS_TOKEN_KEY = "ksef.auth.access_token" + ACCESS_TOKEN_VALID_UNTIL_KEY = "ksef.auth.access_token_valid_until" + REFRESH_TOKEN_KEY = "ksef.auth.refresh_token" + REFRESH_TOKEN_VALID_UNTIL_KEY = "ksef.auth.refresh_token_valid_until" + + def __init__(self, company_id=None): + self.company_id = company_id + self.environment = self._resolve_environment(company_id=company_id) + configured_base_url = SettingsService.get( + "ksef.base_url", + self.ENVIRONMENT_URLS[self.environment], + company_id=company_id, + ) + self.base_url = self._normalize_base_url(configured_base_url or self.ENVIRONMENT_URLS[self.environment]) + self.auth_mode = SettingsService.get_effective("ksef.auth_mode", "token", company_id=company_id, scope_name='ksef', user_default='user') + self.token = (self._settings_get_secret("ksef.token", "") or "").strip() + self.client_id = (SettingsService.get_effective("ksef.client_id", "", company_id=company_id, scope_name='ksef', user_default='user') or "").strip() + self.certificate_name = (SettingsService.get_effective("ksef.certificate_name", "", company_id=company_id, scope_name='ksef', user_default='user') or "").strip() + self.certificate_data = self._settings_get_secret("ksef.certificate_data", "") or "" + self.tax_id = self._resolve_tax_id() + + self._runtime_secret_cache: dict[str, str] = {} + self._runtime_value_cache: dict[str, str] = {} + + def _settings_get_secret(self, key: str, default: str = "") -> str: + getter = getattr(SettingsService, "get_secret", None) + if callable(getter): + return getter(key, default, company_id=self.company_id) + return SettingsService.get(key, default, company_id=self.company_id) + + @classmethod + def _resolve_environment(cls, company_id=None) -> str: + environment = (SettingsService.get_effective("ksef.environment", "prod", company_id=company_id, scope_name='ksef', user_default='user') or "").strip().lower() + if environment in cls.ENVIRONMENT_URLS: + return environment + + base_url = (SettingsService.get_effective("ksef.base_url", "", company_id=company_id, scope_name='ksef', user_default='user') or "").strip().lower() + if "api-test.ksef.mf.gov.pl" in base_url: + return "test" + return "prod" + + @staticmethod + def _normalize_base_url(base_url: str) -> str: + base = (base_url or "https://api.ksef.mf.gov.pl/v2").strip().rstrip("/") + if base.endswith("/docs/v2"): + return base[:-8] + "/v2" + if not base.endswith("/v2"): + base = f"{base}/v2" + return base.rstrip("/") + + def _resolve_tax_id(self) -> str: + candidates = [ + SettingsService.get("company.tax_id", "", company_id=self.company_id), + SettingsService.get("company.nip", "", company_id=self.company_id), + SettingsService.get("tax_id", "", company_id=self.company_id), + SettingsService.get("nip", "", company_id=self.company_id), + ] + + try: + from app.models.company import Company + + if self.company_id: + company = Company.query.get(self.company_id) + if company: + candidates.extend([ + getattr(company, "tax_id", ""), + getattr(company, "nip", ""), + getattr(company, "vat_number", ""), + ]) + except Exception: + pass + + for value in candidates: + digits = re.sub(r"\D", "", value or "") + if len(digits) == 10: + return digits + + return "" + + @staticmethod + def _parse_datetime(value: str | None) -> datetime | None: + if not value: + return None + raw = value.strip() + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + try: + return datetime.fromisoformat(raw) + except ValueError: + return None + + @staticmethod + def _ensure_naive_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt + return dt.astimezone(timezone.utc).replace(tzinfo=None) + + def _get_cached_secret(self, key: str) -> str: + if key in self._runtime_secret_cache: + return (self._runtime_secret_cache.get(key) or "").strip() + return (self._settings_get_secret(key, "") or "").strip() + + def _set_cached_secret(self, key: str, value: str): + self._runtime_secret_cache[key] = value or "" + + def _get_cached_value(self, key: str) -> str: + if key in self._runtime_value_cache: + return (self._runtime_value_cache.get(key) or "").strip() + return (SettingsService.get(key, "", company_id=self.company_id) or "").strip() + + def _set_cached_value(self, key: str, value: str): + self._runtime_value_cache[key] = value or "" + + def _clear_access_token_cache(self): + self._set_cached_secret(self.ACCESS_TOKEN_KEY, "") + self._set_cached_value(self.ACCESS_TOKEN_VALID_UNTIL_KEY, "") + + def _clear_refresh_token_cache(self): + self._set_cached_secret(self.REFRESH_TOKEN_KEY, "") + self._set_cached_value(self.REFRESH_TOKEN_VALID_UNTIL_KEY, "") + + def _access_token_is_valid(self) -> bool: + token = self._get_cached_secret(self.ACCESS_TOKEN_KEY) + valid_until = self._parse_datetime(self._get_cached_value(self.ACCESS_TOKEN_VALID_UNTIL_KEY)) + if not token or not valid_until: + return False + return valid_until > datetime.now(timezone.utc) + timedelta(seconds=30) + + def _refresh_token_is_valid(self) -> bool: + token = self._get_cached_secret(self.REFRESH_TOKEN_KEY) + valid_until = self._parse_datetime(self._get_cached_value(self.REFRESH_TOKEN_VALID_UNTIL_KEY)) + if not token or not valid_until: + return False + return valid_until > datetime.now(timezone.utc) + timedelta(seconds=30) + + def _get_public_key_pem(self) -> str: + url = f"{self.base_url}/security/public-key-certificates" + response = requests.get( + url, + headers={"Accept": "application/json"}, + timeout=30, + ) + response.raise_for_status() + + payload = response.json() + if not isinstance(payload, list) or not payload: + raise RuntimeError("KSeF nie zwrócił listy certyfikatów klucza publicznego.") + + for item in payload: + if not isinstance(item, dict): + continue + + raw_cert = item.get("certificate") + if not raw_cert: + continue + + raw_cert = raw_cert.strip() + + if "BEGIN CERTIFICATE" in raw_cert: + return raw_cert + + wrapped = "\n".join( + raw_cert[i:i + 64] for i in range(0, len(raw_cert), 64) + ) + return f"-----BEGIN CERTIFICATE-----\n{wrapped}\n-----END CERTIFICATE-----" + + raise RuntimeError("Nie udało się odczytać certyfikatu klucza publicznego KSeF.") + + @staticmethod + def _sha256_base64(payload: bytes) -> str: + return base64.b64encode(hashlib.sha256(payload).digest()).decode("ascii") + + @staticmethod + def _normalize_xml_bytes(xml_content: str) -> bytes: + if xml_content is None: + raise RuntimeError("Brak treści XML faktury do wysyłki.") + xml_bytes = xml_content.encode("utf-8") + if xml_bytes.startswith(b"\xef\xbb\xbf"): + xml_bytes = xml_bytes[3:] + if len(xml_bytes) > 1_000_000: + raise RuntimeError("Plik XML faktury przekracza limit 1 MB wymagany przez KSeF dla faktury bez załączników.") + return xml_bytes + + def _build_online_encryption_data(self) -> dict[str, str | bytes]: + if not x509 or not serialization or not hashes or not padding or not Cipher or not algorithms or not modes or not sym_padding: + raise RuntimeError( + "Brak biblioteki cryptography. Zainstaluj ją, aby obsłużyć wysyłkę faktur do KSeF 2.x." + ) + + key = self._get_public_key_pem().encode("utf-8") + cert = x509.load_pem_x509_certificate(key) + public_key = cert.public_key() + + symmetric_key = __import__("secrets").token_bytes(32) + iv = __import__("secrets").token_bytes(16) + encrypted_key = public_key.encrypt( + symmetric_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + return { + "symmetric_key": symmetric_key, + "iv": iv, + "encrypted_symmetric_key": base64.b64encode(encrypted_key).decode("ascii"), + "initialization_vector": base64.b64encode(iv).decode("ascii"), + } + + @staticmethod + def _encrypt_invoice_bytes(invoice_bytes: bytes, symmetric_key: bytes, iv: bytes) -> bytes: + padder = sym_padding.PKCS7(128).padder() + padded = padder.update(invoice_bytes) + padder.finalize() + cipher = Cipher(algorithms.AES(symmetric_key), modes.CBC(iv)) + encryptor = cipher.encryptor() + encrypted = encryptor.update(padded) + encryptor.finalize() + return iv + encrypted + + @staticmethod + def _extract_status_code(payload: dict | None) -> int | None: + if not isinstance(payload, dict): + return None + status = payload.get("status") + if isinstance(status, dict): + status = status.get("code") + elif isinstance(status, list) and status: + head = status[0] + status = head.get("code") if isinstance(head, dict) else head + if status is None and isinstance(payload.get("code"), int): + status = payload.get("code") + try: + return int(status) if status is not None else None + except Exception: + return None + + @staticmethod + def _extract_status_description(payload: dict | None) -> str: + if not isinstance(payload, dict): + return "" + status = payload.get("status") + if isinstance(status, dict): + parts = [status.get("description") or ""] + details = status.get("details") or [] + if isinstance(details, list): + parts.extend(str(item) for item in details if item) + return " | ".join(part for part in parts if part) + if payload.get("description"): + return str(payload.get("description")) + return "" + + @staticmethod + def _first_present(mapping: dict | None, *keys: str): + if not isinstance(mapping, dict): + return None + for key in keys: + value = mapping.get(key) + if value not in (None, "", []): + return value + return None + + def _request_binary(self, method, path, params=None, json=None, data=None, accept="application/octet-stream", content_type=None, use_auth=True): + last_error = None + url = f"{self.base_url}/{path.lstrip('/')}" + for attempt in range(3): + try: + headers = self._headers(use_auth=use_auth, accept=accept) + if content_type: + headers["Content-Type"] = content_type + response = requests.request( + method, + url, + headers=headers, + params=params, + json=json, + data=data, + timeout=30, + ) + if response.status_code == 401 and use_auth and attempt == 0: + self._clear_access_token_cache() + headers = self._headers(use_auth=use_auth, accept=accept) + if content_type: + headers["Content-Type"] = content_type + response = requests.request( + method, + url, + headers=headers, + params=params, + json=json, + data=data, + timeout=30, + ) + response.raise_for_status() + return response.content + except requests.HTTPError as exc: + response = getattr(exc, "response", None) + if response is not None and response.status_code == 429: + retry_after = response.headers.get("Retry-After") + wait_hint = f" Spróbuj ponownie za około {retry_after} s." if retry_after else " Spróbuj ponownie za chwilę." + last_error = RuntimeError("KSeF chwilowo ogranicza liczbę zapytań (HTTP 429)." + wait_hint) + else: + last_error = exc + time.sleep(2 ** attempt) + except Exception as exc: + last_error = exc + time.sleep(2 ** attempt) + raise last_error + + def _open_online_session(self, schema_version: str, encryption_data: dict[str, str | bytes]) -> dict: + open_payload = { + "formCode": { + "systemCode": SettingsService.get_effective("ksef.form_code_system_code", "FA (3)", company_id=self.company_id, scope_name="ksef", user_default="user"), + "schemaVersion": SettingsService.get_effective("ksef.form_code_schema_version", "1-0E", company_id=self.company_id, scope_name="ksef", user_default="user"), + "value": SettingsService.get_effective("ksef.form_code_value", "FA", company_id=self.company_id, scope_name="ksef", user_default="user"), + }, + "encryption": { + "encryptedSymmetricKey": encryption_data["encrypted_symmetric_key"], + "initializationVector": encryption_data["initialization_vector"], + }, + } + if schema_version and schema_version.upper().startswith("FA("): + open_payload["formCode"]["systemCode"] = schema_version.upper().replace("(", " (") + return self._request("POST", "/sessions/online", json=open_payload) + + def _get_session_invoice_status(self, session_reference: str, invoice_reference: str) -> dict: + return self._request("GET", f"/sessions/{session_reference}/invoices/{invoice_reference}") + + def _poll_invoice_processing(self, session_reference: str, invoice_reference: str, timeout_seconds: int = 60) -> dict: + deadline = time.time() + timeout_seconds + last_payload: dict[str, Any] | None = None + while time.time() < deadline: + payload = self._get_session_invoice_status(session_reference, invoice_reference) + last_payload = payload if isinstance(payload, dict) else {"payload": payload} + code = self._extract_status_code(last_payload) + if code == 200: + return last_payload + if code and code >= 300: + description = self._extract_status_description(last_payload) or f"KSeF odrzucił fakturę. Kod statusu: {code}." + raise RuntimeError(description) + time.sleep(2) + return last_payload or {} + + def _fetch_invoice_upo(self, session_reference: str, invoice_reference: str, ksef_number: str | None = None) -> str | None: + try: + if ksef_number: + raw = self._request_binary("GET", f"/sessions/{session_reference}/invoices/ksef/{ksef_number}/upo", accept="application/xml") + else: + raw = self._request_binary("GET", f"/sessions/{session_reference}/invoices/{invoice_reference}/upo", accept="application/xml") + return raw.decode("utf-8", errors="replace") if raw else None + except Exception: + return None + + def _encrypt_token_payload(self, plain_text: str) -> str: + if not x509 or not serialization or not hashes or not padding: + raise RuntimeError( + "Brak biblioteki cryptography. Zainstaluj ją, aby obsłużyć logowanie tokenem KSeF 2.x." + ) + + pem = self._get_public_key_pem().encode("utf-8") + cert = x509.load_pem_x509_certificate(pem) + public_key = cert.public_key() + + encrypted = public_key.encrypt( + plain_text.encode("utf-8"), + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + return base64.b64encode(encrypted).decode("ascii") + + def _build_context_identifier(self) -> dict[str, str]: + if not self.tax_id: + raise RuntimeError( + f"Brak NIP firmy w ustawieniach lub rekordzie firmy dla company_id={self.company_id}. " + "Token KSeF wymaga contextIdentifier typu Nip." + ) + return {"type": "Nip", "value": self.tax_id} + + def _authenticate_with_refresh_token(self) -> bool: + refresh_token = self._get_cached_secret(self.REFRESH_TOKEN_KEY) + if not refresh_token: + return False + + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {refresh_token}", + } + if self.client_id: + headers["X-Client-Id"] = self.client_id + + response = requests.post( + f"{self.base_url}/auth/token/refresh", + headers=headers, + timeout=30, + ) + if response.status_code >= 400: + self._clear_refresh_token_cache() + return False + + data = response.json() + access = data.get("accessToken") or {} + refresh = data.get("refreshToken") or {} + + if not access.get("token"): + self._clear_refresh_token_cache() + return False + + self._set_cached_secret(self.ACCESS_TOKEN_KEY, access["token"]) + self._set_cached_value(self.ACCESS_TOKEN_VALID_UNTIL_KEY, access.get("validUntil", "")) + + if refresh.get("token"): + self._set_cached_secret(self.REFRESH_TOKEN_KEY, refresh["token"]) + self._set_cached_value(self.REFRESH_TOKEN_VALID_UNTIL_KEY, refresh.get("validUntil", "")) + + return True + + def _authenticate_with_ksef_token(self): + if not self.token: + raise RuntimeError("Brak tokenu KSeF w ustawieniach użytkownika.") + + challenge_response = requests.post( + f"{self.base_url}/auth/challenge", + headers={"Accept": "application/json"}, + timeout=30, + ) + challenge_response.raise_for_status() + challenge_data = challenge_response.json() + challenge = challenge_data["challenge"] + + challenge_timestamp_ms = challenge_data.get("timestampMs") + if challenge_timestamp_ms is None: + raise RuntimeError("KSeF nie zwrócił timestampMs w odpowiedzi /auth/challenge.") + + try: + challenge_timestamp_ms = int(challenge_timestamp_ms) + except Exception as exc: + raise RuntimeError("Nieprawidłowy timestampMs z /auth/challenge.") from exc + + encrypted_token = self._encrypt_token_payload( + f"{self.token}|{challenge_timestamp_ms}" + ) + + init_payload = { + "challenge": challenge, + "contextIdentifier": self._build_context_identifier(), + "encryptedToken": encrypted_token, + } + + init_headers = {"Accept": "application/json"} + if self.client_id: + init_headers["X-Client-Id"] = self.client_id + + init_response = requests.post( + f"{self.base_url}/auth/ksef-token", + headers=init_headers, + json=init_payload, + timeout=30, + ) + init_response.raise_for_status() + init_data = init_response.json() + + auth_token = (init_data.get("authenticationToken") or {}).get("token") + reference_number = init_data.get("referenceNumber") + if not auth_token or not reference_number: + raise RuntimeError("KSeF nie zwrócił authenticationToken lub referenceNumber.") + + status_headers = { + "Accept": "application/json", + "Authorization": f"Bearer {auth_token}", + } + if self.client_id: + status_headers["X-Client-Id"] = self.client_id + + for _ in range(20): + status_response = requests.get( + f"{self.base_url}/auth/{reference_number}", + headers=status_headers, + timeout=30, + ) + status_response.raise_for_status() + + payload = status_response.json() + status = payload.get("status") or {} + code = status.get("code") + + if code == 200: + break + + if code and code >= 300: + description = status.get("description") or "Błąd uwierzytelnienia KSeF." + details = status.get("details") or [] + details_text = " | ".join(str(x) for x in details if x) + if details_text: + raise RuntimeError(f"{description} Details: {details_text}") + raise RuntimeError(description) + + time.sleep(1) + else: + raise RuntimeError( + f"Przekroczono czas oczekiwania na zakończenie uwierzytelnienia KSeF dla {reference_number}." + ) + + redeem_headers = { + "Accept": "application/json", + "Authorization": f"Bearer {auth_token}", + } + if self.client_id: + redeem_headers["X-Client-Id"] = self.client_id + + redeem_response = requests.post( + f"{self.base_url}/auth/token/redeem", + headers=redeem_headers, + timeout=30, + ) + redeem_response.raise_for_status() + tokens = redeem_response.json() + + access = tokens.get("accessToken") or {} + refresh = tokens.get("refreshToken") or {} + + access_token = access.get("token") + refresh_token = refresh.get("token") + + if not access_token: + raise RuntimeError("KSeF nie zwrócił access tokena.") + + self._set_cached_secret(self.ACCESS_TOKEN_KEY, access_token) + self._set_cached_value(self.ACCESS_TOKEN_VALID_UNTIL_KEY, access.get("validUntil", "")) + + if refresh_token: + self._set_cached_secret(self.REFRESH_TOKEN_KEY, refresh_token) + self._set_cached_value(self.REFRESH_TOKEN_VALID_UNTIL_KEY, refresh.get("validUntil", "")) + + def _ensure_access_token(self) -> str: + if self._access_token_is_valid(): + return self._get_cached_secret(self.ACCESS_TOKEN_KEY) + + if self._refresh_token_is_valid() and self._authenticate_with_refresh_token(): + return self._get_cached_secret(self.ACCESS_TOKEN_KEY) + + if self.auth_mode != "token": + raise RuntimeError(f"Nieobsługiwany tryb autoryzacji KSeF: {self.auth_mode}") + + self._authenticate_with_ksef_token() + token = self._get_cached_secret(self.ACCESS_TOKEN_KEY) + if not token: + raise RuntimeError("Nie udało się uzyskać access tokena KSeF.") + return token + + def _headers(self, use_auth: bool = True, accept: str = "application/json") -> dict[str, str]: + headers = {"Accept": accept} + if self.client_id: + headers["X-Client-Id"] = self.client_id + if use_auth: + headers["Authorization"] = f"Bearer {self._ensure_access_token()}" + return headers + + def _request(self, method, path, params=None, json=None, accept="application/json", use_auth=True): + last_error = None + url = f"{self.base_url}/{path.lstrip('/')}" + for attempt in range(3): + try: + headers = self._headers(use_auth=use_auth, accept=accept) + response = requests.request( + method, + url, + headers=headers, + params=params, + json=json, + timeout=30, + ) + + if response.status_code == 401 and use_auth and attempt == 0: + self._clear_access_token_cache() + headers = self._headers(use_auth=use_auth, accept=accept) + response = requests.request( + method, + url, + headers=headers, + params=params, + json=json, + timeout=30, + ) + + response.raise_for_status() + + if not response.content: + return {"status": "ok"} + + content_type = response.headers.get("Content-Type", "") + if "application/json" in content_type or accept == "application/json": + return response.json() + return response.text + except requests.HTTPError as exc: + response = getattr(exc, 'response', None) + if response is not None and response.status_code == 429: + retry_after = response.headers.get('Retry-After') + wait_hint = f' Spróbuj ponownie za około {retry_after} s.' if retry_after else ' Spróbuj ponownie za chwilę.' + last_error = RuntimeError('KSeF chwilowo ogranicza liczbę zapytań (HTTP 429).' + wait_hint) + else: + last_error = exc + time.sleep(2 ** attempt) + except Exception as exc: + last_error = exc + time.sleep(2 ** attempt) + raise last_error + + @staticmethod + def _decimal_from_item(item: dict, *keys: str) -> Decimal: + for key in keys: + if item.get(key) is not None: + return Decimal(str(item.get(key))) + return Decimal("0") + + @staticmethod + def _clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def _digits_only(value: Any) -> str: + return re.sub(r"\D", "", str(value or "")) + + def _pick_contractor(self, item: dict) -> tuple[str, str]: + seller = item.get("seller") or {} + buyer = item.get("buyer") or {} + + seller_nip = re.sub(r"\D", "", seller.get("nip") or ((seller.get("identifier") or {}).get("value", ""))) + buyer_nip = re.sub(r"\D", "", buyer.get("nip") or ((buyer.get("identifier") or {}).get("value", ""))) + + if self.tax_id and seller_nip == self.tax_id: + contractor = buyer + elif self.tax_id and buyer_nip == self.tax_id: + contractor = seller + else: + contractor = buyer or seller + + identifier = contractor.get("identifier") or {} + nip = contractor.get("nip") or identifier.get("value", "") + return contractor.get("name", "Brak"), nip + + @staticmethod + def _strip_namespace(tag: str) -> str: + if "}" in tag: + return tag.split("}", 1)[1] + return tag + + def _find_first_text(self, root: ET.Element, local_names: list[str]) -> str: + wanted = {name.lower() for name in local_names} + for element in root.iter(): + if self._strip_namespace(element.tag).lower() in wanted: + text = self._clean_text(element.text) + if text: + return text + return "" + + def _find_first_child_text(self, element: ET.Element, local_names: list[str]) -> str: + wanted = {name.lower() for name in local_names} + for node in element.iter(): + if self._strip_namespace(node.tag).lower() in wanted: + text = self._clean_text(node.text) + if text: + return text + return "" + + def _find_direct_child(self, element: ET.Element, local_names: list[str]) -> ET.Element | None: + wanted = {name.lower() for name in local_names} + for child in list(element): + if self._strip_namespace(child.tag).lower() in wanted: + return child + return None + + def _find_party_nodes(self, root: ET.Element) -> list[ET.Element]: + result: list[ET.Element] = [] + wanted = { + "podmiot1", + "podmiot2", + "sprzedawca", + "nabywca", + "sprzedawca1", + "nabywca1", + } + + for element in root.iter(): + local_name = self._strip_namespace(element.tag).lower() + if local_name in wanted: + result.append(element) + + return result + + def _parse_adres_node(self, address_node: ET.Element | None) -> dict[str, str]: + result = { + "street": "", + "city": "", + "postal_code": "", + "country": "", + "address": "", + } + if address_node is None: + return result + + def get_text(names: list[str]) -> str: + return self._find_first_child_text(address_node, names) + + def split_adres_l2(raw: str) -> tuple[str, str]: + cleaned = self._clean_text(raw) + if not cleaned: + return "", "" + match = re.match(r"^(\d{2}-\d{3})\s+(.+)$", cleaned) + if match: + return match.group(1), self._clean_text(match.group(2)) + return "", cleaned + + street = get_text(["Ulica", "AdresL1"]) + house_no = get_text(["NrDomu"]) + apartment_no = get_text(["NrLokalu"]) + city = get_text(["Miejscowosc"]) + postal_code = get_text(["KodPocztowy"]) + country = get_text(["Kraj", "KodKraju"]) + adres_l2 = get_text(["AdresL2"]) + + if adres_l2: + parsed_postal_code, parsed_city = split_adres_l2(adres_l2) + postal_code = postal_code or parsed_postal_code + city = city or parsed_city + + street_line = self._clean_text(street) + if not street_line: + street_parts = [part for part in [street, house_no] if part] + street_line = " ".join(street_parts).strip() + if apartment_no: + street_line = f"{street_line}/{apartment_no}" if street_line else apartment_no + + address_parts = [part for part in [street_line, postal_code, city, country] if part] + full_address = ", ".join(address_parts) + + result["street"] = street_line + result["city"] = city + result["postal_code"] = postal_code + result["country"] = country + result["address"] = full_address + return result + + def _parse_party_element(self, element: ET.Element) -> dict[str, str]: + name = "" + nip = "" + + identity_node = self._find_direct_child(element, ["DaneIdentyfikacyjne"]) + if identity_node is not None: + name = self._find_first_child_text(identity_node, ["PelnaNazwa", "Nazwa", "NazwaPodmiotu"]) + nip = self._digits_only(self._find_first_child_text(identity_node, ["NIP", "NrIdentyfikacjiPodatkowej"])) + + if not name: + name = self._find_first_child_text(element, ["PelnaNazwa", "Nazwa", "NazwaPodmiotu"]) + if not nip: + nip = self._digits_only(self._find_first_child_text(element, ["NIP", "NrIdentyfikacjiPodatkowej"])) + + main_address_node = self._find_direct_child(element, ["Adres"]) + correspondence_address_node = self._find_direct_child(element, ["AdresKoresp"]) + + main_address = self._parse_adres_node(main_address_node) + correspondence_address = self._parse_adres_node(correspondence_address_node) + + chosen_address = main_address + if not chosen_address.get("address") and correspondence_address.get("address"): + chosen_address = correspondence_address + + return { + "name": name, + "nip": nip, + "street": chosen_address.get("street", ""), + "city": chosen_address.get("city", ""), + "postal_code": chosen_address.get("postal_code", ""), + "country": chosen_address.get("country", ""), + "address": chosen_address.get("address", ""), + } + + def _match_party_role_from_root( + self, + root: ET.Element, + contractor_nip: str = "", + contractor_name: str = "", + ) -> dict[str, str]: + empty_result = { + "name": "", + "nip": "", + "street": "", + "city": "", + "postal_code": "", + "country": "", + "address": "", + } + + normalized_tax_id = self._digits_only(self.tax_id) + normalized_contractor_nip = self._digits_only(contractor_nip) + normalized_contractor_name = self._clean_text(contractor_name).lower() + + podmiot1 = None + podmiot2 = None + for node in self._find_party_nodes(root): + local_name = self._strip_namespace(node.tag).lower() + if local_name == "podmiot1" and podmiot1 is None: + podmiot1 = self._parse_party_element(node) + elif local_name == "podmiot2" and podmiot2 is None: + podmiot2 = self._parse_party_element(node) + + if podmiot1 or podmiot2: + if normalized_tax_id: + if podmiot1 and self._digits_only(podmiot1.get("nip")) == normalized_tax_id: + return podmiot2 or empty_result + if podmiot2 and self._digits_only(podmiot2.get("nip")) == normalized_tax_id: + return podmiot1 or empty_result + + if normalized_contractor_nip: + if podmiot1 and self._digits_only(podmiot1.get("nip")) == normalized_contractor_nip: + return podmiot1 + if podmiot2 and self._digits_only(podmiot2.get("nip")) == normalized_contractor_nip: + return podmiot2 + + if normalized_contractor_name: + if podmiot1 and self._clean_text(podmiot1.get("name")).lower() == normalized_contractor_name: + return podmiot1 + if podmiot2 and self._clean_text(podmiot2.get("name")).lower() == normalized_contractor_name: + return podmiot2 + + return podmiot2 or podmiot1 or empty_result + + parties = [self._parse_party_element(node) for node in self._find_party_nodes(root)] + parties = [party for party in parties if any(party.values())] + + if normalized_contractor_nip: + for party in parties: + if self._digits_only(party.get("nip")) == normalized_contractor_nip: + return party + + if normalized_contractor_name: + for party in parties: + party_name = self._clean_text(party.get("name")).lower() + if party_name and party_name == normalized_contractor_name: + return party + + return parties[0] if parties else empty_result + + def _extract_party_details_from_xml( + self, + xml_content: str, + contractor_nip: str = "", + contractor_name: str = "", + ) -> dict[str, str]: + empty_result = { + "name": "", + "nip": "", + "street": "", + "city": "", + "postal_code": "", + "country": "", + "address": "", + } + + if not xml_content: + return empty_result + + try: + root = ET.fromstring(xml_content) + except Exception: + return empty_result + + matched_party = self._match_party_role_from_root( + root=root, + contractor_nip=contractor_nip, + contractor_name=contractor_name, + ) + if matched_party and any(matched_party.values()): + return matched_party + + street = self._find_first_text(root, ["Ulica", "AdresL1"]) + house_no = self._find_first_text(root, ["NrDomu"]) + apartment_no = self._find_first_text(root, ["NrLokalu"]) + city = self._find_first_text(root, ["Miejscowosc"]) + postal_code = self._find_first_text(root, ["KodPocztowy"]) + country = self._find_first_text(root, ["Kraj", "KodKraju"]) + adres_l2 = self._find_first_text(root, ["AdresL2"]) + + if adres_l2 and (not postal_code or not city): + match = re.match(r"^(\d{2}-\d{3})\s+(.+)$", self._clean_text(adres_l2)) + if match: + postal_code = postal_code or match.group(1) + city = city or self._clean_text(match.group(2)) + elif not city: + city = self._clean_text(adres_l2) + + street_parts = [part for part in [street, house_no] if part] + street_line = " ".join(street_parts).strip() + if apartment_no: + street_line = f"{street_line}/{apartment_no}" if street_line else apartment_no + + address_parts = [part for part in [street_line, postal_code, city, country] if part] + address = ", ".join(address_parts) + + return { + "name": "", + "nip": "", + "street": street_line, + "city": city, + "postal_code": postal_code, + "country": country, + "address": address, + } + + def _enrich_metadata_with_xml_party_data( + self, + item: dict[str, Any], + xml_content: str, + contractor_name: str, + contractor_nip: str, + ) -> dict[str, Any]: + metadata = dict(item or {}) + party = self._extract_party_details_from_xml( + xml_content=xml_content, + contractor_nip=contractor_nip, + contractor_name=contractor_name, + ) + + metadata.pop("contractor_regon", None) + metadata["contractor_street"] = party.get("street", "") + metadata["contractor_city"] = party.get("city", "") + metadata["contractor_postal_code"] = party.get("postal_code", "") + metadata["contractor_country"] = party.get("country", "") + metadata["contractor_address"] = party.get("address", "") + + if party.get("name") and contractor_name == "Brak": + metadata["contractor_name_from_xml"] = party.get("name", "") + + if party.get("nip") and not contractor_nip: + metadata["contractor_nip_from_xml"] = party.get("nip", "") + + return metadata + + def list_documents(self, since=None): + now = datetime.utcnow() + date_from = self._ensure_naive_utc(since) if since else (now - timedelta(days=30)) + + request_body = { + "subjectType": "Subject2", + "dateRange": { + "dateType": "PermanentStorage", + "from": date_from.isoformat(), + "to": now.isoformat(), + }, + "pageOffset": 0, + "pageSize": 100, + } + + data = self._request("POST", "/invoices/query/metadata", json=request_body) + invoices = data.get("invoices", []) + documents = [] + + for item in invoices: + issue_date_raw = item.get("issueDate") or now.date().isoformat() + issue_date = date.fromisoformat(issue_date_raw) + + fetched_at = ( + self._parse_datetime(item.get("acquisitionDate")) + or self._parse_datetime(item.get("permanentStorageDate")) + or datetime.utcnow().replace(tzinfo=timezone.utc) + ) + fetched_at = fetched_at.astimezone(timezone.utc).replace(tzinfo=None) + + contractor_name, contractor_nip = self._pick_contractor(item) + xml_content = "" + ksef_number = item["ksefNumber"] + + try: + xml_content = self._request( + "GET", + f"/invoices/ksef/{ksef_number}", + accept="application/xml", + ) + except Exception: + xml_content = "" + + enriched_metadata = self._enrich_metadata_with_xml_party_data( + item=item, + xml_content=xml_content, + contractor_name=contractor_name, + contractor_nip=contractor_nip, + ) + + if enriched_metadata.get("contractor_name_from_xml") and contractor_name == "Brak": + contractor_name = enriched_metadata["contractor_name_from_xml"] + + if enriched_metadata.get("contractor_nip_from_xml") and not contractor_nip: + contractor_nip = enriched_metadata["contractor_nip_from_xml"] + + documents.append( + KSeFDocument( + ksef_number=ksef_number, + invoice_number=item.get("invoiceNumber", ksef_number), + contractor_name=contractor_name, + contractor_nip=contractor_nip, + issue_date=issue_date, + received_date=issue_date, + fetched_at=fetched_at, + net_amount=self._decimal_from_item(item, "netAmount", "invoiceNetAmount"), + vat_amount=self._decimal_from_item(item, "vatAmount", "invoiceVatAmount"), + gross_amount=self._decimal_from_item(item, "grossAmount", "invoiceGrossAmount"), + invoice_type="purchase", + xml_content=xml_content, + metadata=enriched_metadata, + ) + ) + return documents + + def issue_invoice(self, payload: dict): + xml_content = (payload or {}).get("xml_content") + if not xml_content: + raise RuntimeError("Brak xml_content w payloadzie wysyłki do KSeF.") + + schema_version = str((payload or {}).get("schemaVersion") or "FA(3)") + xml_bytes = self._normalize_xml_bytes(xml_content) + encryption_data = self._build_online_encryption_data() + encrypted_invoice = self._encrypt_invoice_bytes( + xml_bytes, + encryption_data["symmetric_key"], + encryption_data["iv"], + ) + + session_response = self._open_online_session(schema_version=schema_version, encryption_data=encryption_data) + session_reference = self._first_present(session_response, "referenceNumber", "sessionReferenceNumber") + if not session_reference: + raise RuntimeError("KSeF nie zwrócił numeru referencyjnego sesji interaktywnej.") + + send_payload = { + "invoiceHash": self._sha256_base64(xml_bytes), + "invoiceSize": len(xml_bytes), + "encryptedDocumentHash": self._sha256_base64(encrypted_invoice), + "encryptedDocumentSize": len(encrypted_invoice), + "encryptedDocumentContent": base64.b64encode(encrypted_invoice).decode("ascii"), + } + send_response = self._request("POST", f"/sessions/online/{session_reference}/invoices", json=send_payload) + invoice_reference = self._first_present(send_response, "referenceNumber", "invoiceReferenceNumber") + if not invoice_reference: + raise RuntimeError("KSeF nie zwrócił numeru referencyjnego przesłanej faktury.") + + close_error = None + try: + self._request("POST", f"/sessions/online/{session_reference}/close") + except Exception as exc: + close_error = exc + + status_payload = self._poll_invoice_processing(session_reference, invoice_reference) + code = self._extract_status_code(status_payload) + ksef_number = self._first_present( + status_payload, + "ksefNumber", + "ksefReferenceNumber", + "invoiceKsefNumber", + ) + if not ksef_number and isinstance(status_payload.get("invoice"), dict): + ksef_number = self._first_present(status_payload.get("invoice"), "ksefNumber", "ksefReferenceNumber") + + upo_xml = self._fetch_invoice_upo(session_reference, invoice_reference, ksef_number=str(ksef_number) if ksef_number else None) + message = "Wysłano fakturę do KSeF." + if code == 200 and ksef_number: + message = f"Faktura została przyjęta przez KSeF. Numer KSeF: {ksef_number}." + elif code and code < 300: + message = "Faktura została wysłana do KSeF i oczekuje na końcowe przetworzenie." + if close_error: + message += f" Sesja została zamknięta z ostrzeżeniem: {close_error}" + + return { + "ksef_number": ksef_number or f"PENDING/{session_reference}/{invoice_reference}", + "status": "issued" if code == 200 and ksef_number else "queued", + "message": message, + "xml_content": xml_content, + "session_reference_number": session_reference, + "invoice_reference_number": invoice_reference, + "status_payload": status_payload, + "upo_xml": upo_xml, + } + + def ping(self): + try: + self._request("GET", "/auth/sessions", params={"pageSize": 10}) + return {"status": "ok", "message": "API KSeF odpowiada, a uwierzytelnienie działa poprawnie."} + except Exception as exc: + return {"status": "error", "message": str(exc)} + + def diagnostics(self): + try: + access_token_state = "valid" if self._access_token_is_valid() else "missing_or_expired" + refresh_token_state = "valid" if self._refresh_token_is_valid() else "missing_or_expired" + + sessions = self._request("GET", "/auth/sessions", params={"pageSize": 10}) + sample = sessions if isinstance(sessions, dict) else {"sessions": sessions} + + return { + "status": "ok", + "message": "API KSeF odpowiada.", + "base_url": self.base_url, + "auth_mode": self.auth_mode, + "tax_id": self.tax_id, + "token_configured": bool(self.token), + "certificate_configured": bool(self.certificate_data), + "access_token_state": access_token_state, + "refresh_token_state": refresh_token_state, + "sample": sample, + } + except Exception as exc: + return { + "status": "error", + "message": str(exc), + "base_url": self.base_url, + "auth_mode": self.auth_mode, + "tax_id": self.tax_id, + "token_configured": bool(self.token), + "certificate_configured": bool(self.certificate_data), + "sample": {"error": str(exc)}, + } + + +class KSeFService: + def __init__(self, company_id=None): + use_mock = SettingsService.get("ksef.mock_mode", "true", company_id=company_id) == "true" + self.adapter = MockKSeFAdapter(company_id=company_id) if use_mock else RequestsKSeFAdapter(company_id=company_id) + + def list_documents(self, since=None): + return self.adapter.list_documents(since=since) + + def issue_invoice(self, payload: dict): + return self.adapter.issue_invoice(payload) + + def ping(self): + return self.adapter.ping() + + def diagnostics(self): + return self.adapter.diagnostics() + + @staticmethod + def calc_hash(xml_content: str) -> str: + return hashlib.sha256(xml_content.encode("utf-8")).hexdigest() \ No newline at end of file diff --git a/app/services/mail_service.py b/app/services/mail_service.py new file mode 100644 index 0000000..8a04ab4 --- /dev/null +++ b/app/services/mail_service.py @@ -0,0 +1,104 @@ +from __future__ import annotations +from datetime import datetime +from email.message import EmailMessage +import smtplib + +from app.extensions import db +from app.models.invoice import Invoice, MailDelivery +from app.services.pdf_service import PdfService +from app.services.settings_service import SettingsService + + +class MailService: + def __init__(self, company_id=None): + self.company_id = company_id + + def _smtp_config(self): + security_mode = (SettingsService.get_effective('mail.security_mode', '', company_id=self.company_id) or '').strip().lower() + if security_mode not in {'tls', 'ssl', 'none'}: + security_mode = 'tls' if str(SettingsService.get_effective('mail.tls', 'true', company_id=self.company_id)).lower() == 'true' else 'none' + return { + 'server': (SettingsService.get_effective('mail.server', '', company_id=self.company_id) or '').strip(), + 'port': int(SettingsService.get_effective('mail.port', '587', company_id=self.company_id) or 587), + 'username': (SettingsService.get_effective('mail.username', '', company_id=self.company_id) or '').strip(), + 'password': (SettingsService.get_effective_secret('mail.password', '', company_id=self.company_id) or '').strip(), + 'sender': (SettingsService.get_effective('mail.sender', '', company_id=self.company_id) or '').strip(), + 'security_mode': security_mode, + } + + def send_invoice(self, invoice: Invoice, recipient: str, subject: str | None = None, body: str | None = None): + subject = subject or f'Faktura {invoice.invoice_number}' + body = body or f'W załączeniu faktura {invoice.invoice_number}.' + + delivery = MailDelivery(invoice_id=invoice.id, recipient=recipient, subject=subject, status='queued') + db.session.add(delivery) + db.session.flush() + + pdf_bytes, path = PdfService().render_invoice_pdf(invoice) + + try: + self.send_mail( + recipient, + subject, + body, + [(path.name, 'application/pdf', pdf_bytes)] + ) + delivery.status = 'sent' + delivery.sent_at = datetime.utcnow() + except Exception as exc: + delivery.status = 'error' + delivery.error_message = str(exc) + + db.session.commit() + return delivery + + def send_mail(self, recipient: str, subject: str, body: str, attachments=None): + cfg = self._smtp_config() + attachments = attachments or [] + + if not cfg['server']: + raise RuntimeError('SMTP server not configured') + + sender = cfg['sender'] or cfg['username'] + if not sender: + raise RuntimeError('SMTP sender not configured') + + message = EmailMessage() + message['Subject'] = subject + message['From'] = sender + message['To'] = recipient + message.set_content(body) + + for filename, mime, data in attachments: + maintype, subtype = mime.split('/', 1) + message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) + + if cfg['security_mode'] == 'ssl': + smtp = smtplib.SMTP_SSL(cfg['server'], cfg['port']) + else: + smtp = smtplib.SMTP(cfg['server'], cfg['port']) + + with smtp: + smtp.ehlo() + + if cfg['security_mode'] == 'tls': + smtp.starttls() + smtp.ehlo() + + if cfg['username']: + smtp.login(cfg['username'], cfg['password']) + + smtp.send_message(message) + + return {'status': 'sent'} + + def send_test_mail(self, recipient: str): + return self.send_mail( + recipient, + 'KSeF Manager - test SMTP', + 'To jest testowa wiadomość z aplikacji KSeF Manager.' + ) + + def retry_delivery(self, delivery_id): + delivery = db.session.get(MailDelivery, delivery_id) + return self.send_invoice(delivery.invoice, delivery.recipient, delivery.subject) \ No newline at end of file diff --git a/app/services/notification_service.py b/app/services/notification_service.py new file mode 100644 index 0000000..ebae67e --- /dev/null +++ b/app/services/notification_service.py @@ -0,0 +1,104 @@ +from datetime import datetime +from decimal import Decimal +import requests + +from app.extensions import db +from app.models.company import UserCompanyAccess +from app.models.invoice import NotificationLog +from app.models.user import User +from app.services.mail_service import MailService +from app.services.settings_service import SettingsService + + +class NotificationService: + def __init__(self, company_id=None): + self.company_id = company_id + + def should_notify(self, invoice): + if SettingsService.get_effective('notify.enabled', 'false', company_id=self.company_id) != 'true': + return False + min_amount = Decimal(SettingsService.get_effective('notify.min_amount', '0', company_id=self.company_id) or '0') + return Decimal(invoice.gross_amount) >= min_amount + + def _pushover_credentials(self): + return { + 'token': SettingsService.get_effective_secret('notify.pushover_api_token', '', company_id=self.company_id), + 'user': SettingsService.get_effective('notify.pushover_user_key', '', company_id=self.company_id), + } + + def _email_recipients(self): + if not self.company_id: + return [] + rows = ( + db.session.query(User.email) + .join(UserCompanyAccess, UserCompanyAccess.user_id == User.id) + .filter(UserCompanyAccess.company_id == self.company_id, User.is_blocked.is_(False)) + .order_by(User.email.asc()) + .all() + ) + recipients = [] + seen = set() + for (email,) in rows: + email = (email or '').strip().lower() + if not email or email in seen: + continue + seen.add(email) + recipients.append(email) + return recipients + + def notify_new_invoice(self, invoice): + if not self.should_notify(invoice): + return [] + message = f'Nowa faktura {invoice.invoice_number} / {invoice.contractor_name} / {invoice.gross_amount} PLN' + logs = [self._send_email_notification(invoice, message)] + logs.append(self._send_pushover(invoice.id, message)) + return logs + + def log_channel(self, invoice_id, channel, status, message): + log = NotificationLog(invoice_id=invoice_id, channel=channel, status=status, message=message, sent_at=datetime.utcnow()) + db.session.add(log) + db.session.commit() + return log + + def _send_email_notification(self, invoice, message): + recipients = self._email_recipients() + if not recipients: + return self.log_channel(invoice.id, 'email', 'skipped', 'Brak odbiorców e-mail przypisanych do aktywnej firmy') + + mailer = MailService(company_id=self.company_id) + subject = f'Nowa faktura: {invoice.invoice_number}' + body = ( + 'W systemie KSeF Manager pojawiła się nowa faktura.\n\n' + f'Numer: {invoice.invoice_number}\n' + f'Kontrahent: {invoice.contractor_name}\n' + f'Kwota brutto: {invoice.gross_amount} PLN\n' + ) + + sent = 0 + errors = [] + for recipient in recipients: + try: + mailer.send_mail(recipient, subject, body) + sent += 1 + except Exception as exc: + errors.append(f'{recipient}: {exc}') + + if sent and not errors: + return self.log_channel(invoice.id, 'email', 'sent', f'{message} · odbiorców: {sent}') + if sent and errors: + return self.log_channel(invoice.id, 'email', 'error', f"Wysłano do {sent}/{len(recipients)} odbiorców. Błędy: {'; '.join(errors[:3])}") + return self.log_channel(invoice.id, 'email', 'error', 'Nie udało się wysłać powiadomień e-mail: ' + '; '.join(errors[:3])) + + def _send_pushover(self, invoice_id, message): + creds = self._pushover_credentials() + if not creds['token'] or not creds['user']: + return self.log_channel(invoice_id, 'pushover', 'skipped', 'Brak konfiguracji Pushover') + try: + response = requests.post('https://api.pushover.net/1/messages.json', data={'token': creds['token'], 'user': creds['user'], 'message': message}, timeout=10) + response.raise_for_status() + return self.log_channel(invoice_id, 'pushover', 'sent', message) + except Exception as exc: + return self.log_channel(invoice_id, 'pushover', 'error', str(exc)) + + def send_test_pushover(self): + return self._send_pushover(None, 'KSeF Manager - test Pushover') diff --git a/app/services/pdf_service.py b/app/services/pdf_service.py new file mode 100644 index 0000000..9488a39 --- /dev/null +++ b/app/services/pdf_service.py @@ -0,0 +1,609 @@ +from __future__ import annotations + +from decimal import Decimal +from io import BytesIO +from pathlib import Path +import xml.etree.ElementTree as ET + +from flask import current_app +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle + +from app.models.invoice import InvoiceType + + +class PdfService: + def __init__(self): + self.font_name = self._register_font() + + def _register_font(self): + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + ] + for path in candidates: + if Path(path).exists(): + pdfmetrics.registerFont(TTFont("AppUnicode", path)) + return "AppUnicode" + return "Helvetica" + + def _styles(self): + styles = getSampleStyleSheet() + base = self.font_name + styles["Normal"].fontName = base + styles["Normal"].fontSize = 9.5 + styles["Normal"].leading = 12 + styles.add(ParagraphStyle(name="DocTitle", fontName=base, fontSize=18, leading=22, spaceAfter=4)) + styles.add(ParagraphStyle(name="SectionTitle", fontName=base, fontSize=10, leading=12, spaceAfter=4)) + styles.add(ParagraphStyle(name="Small", fontName=base, fontSize=8, leading=10)) + styles.add(ParagraphStyle(name="Right", fontName=base, fontSize=9.5, leading=12, alignment=2)) + return styles + + @staticmethod + def _money(value, currency="PLN") -> str: + return f"{Decimal(value):,.2f} {currency or 'PLN'}".replace(",", " ").replace(".", ",") + + def _safe(self, value) -> str: + return str(value or "-").replace("&", "&").replace("<", "<").replace(">", ">") + + def _table_base_style(self, extra=None): + styles = [ + ("FONTNAME", (0, 0), (-1, -1), self.font_name), + ("BOX", (0, 0), (-1, -1), 0.7, colors.black), + ("INNERGRID", (0, 0), (-1, -1), 0.5, colors.black), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ] + if extra: + styles.extend(extra) + return TableStyle(styles) + + def _extract_lines_from_xml(self, xml_path): + def to_decimal(value, default='0'): + raw = str(value or '').strip() + if not raw: + return Decimal(default) + raw = raw.replace(' ', '').replace(',', '.') + try: + return Decimal(raw) + except Exception: + return Decimal(default) + + def to_vat_rate(value): + raw = str(value or '').strip().lower() + if not raw: + return Decimal('0') + raw = raw.replace('%', '').replace(' ', '').replace(',', '.') + try: + return Decimal(raw) + except Exception: + return Decimal('0') + + def looks_numeric(value): + raw = str(value or '').strip().replace(' ', '').replace(',', '.') + if not raw: + return False + try: + Decimal(raw) + return True + except Exception: + return False + + if not xml_path: + return [] + + try: + xml_content = Path(xml_path).read_text(encoding='utf-8') + root = ET.fromstring(xml_content) + namespace_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else '' + ns = {'fa': namespace_uri} if namespace_uri else {} + + row_path = './/fa:FaWiersz' if ns else './/FaWiersz' + text_path = lambda name: f'fa:{name}' if ns else name + + lines = [] + for row in root.findall(row_path, ns): + p_8a = row.findtext(text_path('P_8A'), default='', namespaces=ns) + p_8b = row.findtext(text_path('P_8B'), default='', namespaces=ns) + + if looks_numeric(p_8a): + qty_raw = p_8a + unit_raw = p_8b or 'szt.' + else: + unit_raw = p_8a or 'szt.' + qty_raw = p_8b or '1' + + net = to_decimal( + row.findtext(text_path('P_11'), default='', namespaces=ns) + or row.findtext(text_path('P_11A'), default='', namespaces=ns) + or '0' + ) + vat = to_decimal( + row.findtext(text_path('P_12Z'), default='', namespaces=ns) + or row.findtext(text_path('P_11Vat'), default='', namespaces=ns) + or '0' + ) + + lines.append({ + 'description': (row.findtext(text_path('P_7'), default='', namespaces=ns) or '').strip(), + 'quantity': to_decimal(qty_raw, '1'), + 'unit': (unit_raw or 'szt.').strip(), + 'unit_net': to_decimal( + row.findtext(text_path('P_9A'), default='', namespaces=ns) + or row.findtext(text_path('P_9B'), default='', namespaces=ns) + or '0' + ), + 'vat_rate': to_vat_rate(row.findtext(text_path('P_12'), default='0', namespaces=ns)), + 'net_amount': net, + 'vat_amount': vat, + 'gross_amount': net + vat, + }) + return lines + except Exception as exc: + current_app.logger.warning(f'PDF XML line parse error: {exc}') + return [] + + @staticmethod + def _first_non_empty(*values, default=""): + for value in values: + if value is None: + continue + if isinstance(value, str): + if value.strip(): + return value.strip() + continue + text = str(value).strip() + if text: + return text + return default + + @staticmethod + def _normalize_metadata_container(value): + return value if isinstance(value, dict) else {} + + def _get_external_metadata(self, invoice): + return self._normalize_metadata_container(getattr(invoice, "external_metadata", {}) or {}) + + def _get_ksef_metadata(self, invoice): + external_metadata = self._get_external_metadata(invoice) + ksef_meta = self._normalize_metadata_container(external_metadata.get("ksef", {}) or {}) + return ksef_meta + + def _resolve_purchase_seller_data(self, invoice, fallback_name, fallback_tax): + external_metadata = self._get_external_metadata(invoice) + ksef_meta = self._get_ksef_metadata(invoice) + + seller_name = self._first_non_empty( + getattr(invoice, "contractor_name", ""), + external_metadata.get("contractor_name"), + ksef_meta.get("contractor_name"), + fallback_name, + ) + + seller_tax = self._first_non_empty( + getattr(invoice, "contractor_nip", ""), + external_metadata.get("contractor_nip"), + ksef_meta.get("contractor_nip"), + fallback_tax, + ) + + seller_street = self._first_non_empty( + external_metadata.get("contractor_street"), + ksef_meta.get("contractor_street"), + external_metadata.get("seller_street"), + ksef_meta.get("seller_street"), + ) + + seller_city = self._first_non_empty( + external_metadata.get("contractor_city"), + ksef_meta.get("contractor_city"), + external_metadata.get("seller_city"), + ksef_meta.get("seller_city"), + ) + + seller_postal_code = self._first_non_empty( + external_metadata.get("contractor_postal_code"), + ksef_meta.get("contractor_postal_code"), + external_metadata.get("seller_postal_code"), + ksef_meta.get("seller_postal_code"), + ) + + seller_country = self._first_non_empty( + external_metadata.get("contractor_country"), + ksef_meta.get("contractor_country"), + external_metadata.get("seller_country"), + ksef_meta.get("seller_country"), + ) + + seller_address = self._first_non_empty( + external_metadata.get("contractor_address"), + ksef_meta.get("contractor_address"), + external_metadata.get("seller_address"), + ksef_meta.get("seller_address"), + getattr(invoice, "contractor_address", ""), + ) + + if not seller_address: + address_parts = [part for part in [seller_street, seller_postal_code, seller_city, seller_country] if part] + seller_address = ", ".join(address_parts) + + return { + "name": seller_name, + "tax": seller_tax, + "address": seller_address, + } + + + @staticmethod + def _normalize_bank_account(value): + return ''.join(str(value or '').split()) + + def _extract_payment_details_from_xml(self, xml_content): + details = {'payment_form_code': '', 'payment_form_label': '', 'bank_account': '', 'bank_name': '', 'payment_due_date': ''} + if not xml_content: + return details + try: + root = ET.fromstring(xml_content) + namespace_uri = root.tag.split('}')[0].strip('{') if '}' in root.tag else '' + ns = {'fa': namespace_uri} if namespace_uri else {} + def find_text(path): + return (root.findtext(path, default='', namespaces=ns) or '').strip() + form_code = find_text('.//fa:Platnosc/fa:FormaPlatnosci' if ns else './/Platnosc/FormaPlatnosci') + bank_account = find_text('.//fa:Platnosc/fa:RachunekBankowy/fa:NrRB' if ns else './/Platnosc/RachunekBankowy/NrRB') + bank_name = find_text('.//fa:Platnosc/fa:RachunekBankowy/fa:NazwaBanku' if ns else './/Platnosc/RachunekBankowy/NazwaBanku') + payment_due_date = find_text('.//fa:Platnosc/fa:TerminPlatnosci/fa:Termin' if ns else './/Platnosc/TerminPlatnosci/Termin') + labels = {'1': 'gotówka', '2': 'karta', '3': 'bon', '4': 'czek', '5': 'weksel', '6': 'przelew', '7': 'kompensata', '8': 'pobranie', '9': 'akredytywa', '10': 'polecenie zapłaty', '11': 'inny'} + details.update({'payment_form_code': form_code, 'payment_form_label': labels.get(form_code, form_code), 'bank_account': self._normalize_bank_account(bank_account), 'bank_name': bank_name, 'payment_due_date': payment_due_date}) + except Exception: + pass + return details + + def _resolve_payment_details(self, invoice): + external_metadata = self._get_external_metadata(invoice) + details = { + 'payment_form_code': self._first_non_empty(external_metadata.get('payment_form_code')), + 'payment_form_label': self._first_non_empty(external_metadata.get('payment_form_label')), + 'bank_account': self._normalize_bank_account(self._first_non_empty(getattr(invoice, 'seller_bank_account', ''), external_metadata.get('seller_bank_account'))), + 'bank_name': self._first_non_empty(external_metadata.get('seller_bank_name')), + 'payment_due_date': self._first_non_empty(external_metadata.get('payment_due_date')), + } + if getattr(invoice, 'xml_path', None) and (not details['bank_account'] or not details['payment_form_code']): + try: + xml_content = Path(invoice.xml_path).read_text(encoding='utf-8') + parsed = self._extract_payment_details_from_xml(xml_content) + for key, value in parsed.items(): + if value and not details.get(key): + details[key] = value + except Exception: + pass + return details + + def _resolve_seller_bank_account(self, invoice): + details = self._resolve_payment_details(invoice) + account = self._normalize_bank_account(details.get('bank_account', '')) + if account: + return account + if getattr(invoice, 'invoice_type', None) == InvoiceType.PURCHASE: + return '' + company = getattr(invoice, 'company', None) + return self._normalize_bank_account(getattr(company, 'bank_account', '') if company else '') + + def _build_pdf_filename_stem(self, invoice): + raw = self._first_non_empty( + getattr(invoice, "invoice_number", ""), + getattr(invoice, "ksef_number", ""), + "Faktura", + ) + return raw.replace("/", "_") + + def _build_pdf_title(self, invoice, invoice_kind, company_name): + return self._first_non_empty( + getattr(invoice, "invoice_number", ""), + getattr(invoice, "ksef_number", ""), + company_name, + invoice_kind, + "Faktura", + ) + + @staticmethod + def _set_pdf_metadata(canvas, doc, title, author, subject, creator="KSeF Manager"): + try: + canvas.setTitle(title) + except Exception: + pass + try: + canvas.setAuthor(author) + except Exception: + pass + try: + canvas.setSubject(subject) + except Exception: + pass + try: + canvas.setCreator(creator) + except Exception: + pass + + def render_invoice_pdf(self, invoice): + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + leftMargin=14 * mm, + rightMargin=14 * mm, + topMargin=14 * mm, + bottomMargin=14 * mm, + ) + styles = self._styles() + story = [] + + company_name = self._safe(invoice.company.name if invoice.company else "Twoja firma") + company_tax = self._safe(getattr(invoice.company, "tax_id", "") if invoice.company else "") + company_address = self._safe(getattr(invoice.company, "address", "") if invoice.company else "") + + customer = getattr(invoice, "customer", None) + customer_name = self._safe(getattr(customer, "name", invoice.contractor_name)) + customer_tax = self._safe(getattr(customer, "tax_id", invoice.contractor_nip)) + customer_address = self._safe(getattr(customer, "address", "")) + customer_email = self._safe(getattr(customer, "email", "")) + + if invoice.invoice_type == InvoiceType.PURCHASE: + purchase_seller = self._resolve_purchase_seller_data( + invoice=invoice, + fallback_name=invoice.contractor_name, + fallback_tax=invoice.contractor_nip, + ) + + seller_name = self._safe(purchase_seller["name"]) + seller_tax = self._safe(purchase_seller["tax"]) + seller_address = self._safe(purchase_seller["address"]) + + buyer_name = company_name + buyer_tax = company_tax + buyer_address = company_address + buyer_email = "" + else: + seller_name = company_name + seller_tax = company_tax + seller_address = company_address + + buyer_name = customer_name + buyer_tax = customer_tax + buyer_address = customer_address + buyer_email = customer_email + + nfz_meta = (invoice.external_metadata or {}).get("nfz", {}) + invoice_kind = "FAKTURA NFZ" if invoice.source == "nfz" else "FAKTURA VAT" + currency = invoice.currency or "PLN" + + pdf_title = self._build_pdf_title(invoice, invoice_kind, company_name) + pdf_author = self._first_non_empty( + invoice.company.name if invoice.company else "", + "KSeF Manager", + ) + pdf_subject = self._first_non_empty( + invoice_kind, + getattr(invoice, "source", ""), + "Faktura", + ) + + story.append(Paragraph(invoice_kind, styles["DocTitle"])) + header = Table( + [ + [ + Paragraph( + f"Numer faktury: {self._safe(invoice.invoice_number)}
" + f"Data wystawienia: {self._safe(invoice.issue_date)}
" + f"Waluta: {self._safe(currency)}", + styles["Normal"], + ), + Paragraph( + f"Numer KSeF: {self._safe(invoice.ksef_number)}
" + f"Status: {self._safe(invoice.issued_status)}
" + f"Typ źródła: {self._safe(invoice.source)}", + styles["Normal"], + ), + ] + ], + colWidths=[88 * mm, 88 * mm], + ) + header.setStyle(self._table_base_style()) + story.extend([header, Spacer(1, 5 * mm)]) + + buyer_email_html = f"
E-mail: {buyer_email}" if buyer_email not in {"", "-"} else "" + + payment_details = self._resolve_payment_details(invoice) + seller_bank_account = self._safe(self._resolve_seller_bank_account(invoice)) + payment_form_html = ( + f"
Forma płatności: {self._safe(payment_details.get('payment_form_label'))}" + if payment_details.get('payment_form_label') else '' + ) + seller_bank_account_html = ( + f"
Rachunek: {seller_bank_account}" + if seller_bank_account not in {"", "-"} + else "" + ) + seller_bank_name_html = ( + f"
Bank: {self._safe(payment_details.get('bank_name'))}" + if payment_details.get('bank_name') else '' + ) + + parties = Table( + [ + [ + Paragraph( + f"Sprzedawca
{seller_name}
" + f"NIP: {seller_tax}
" + f"Adres: {seller_address}" + f"{payment_form_html}" + f"{seller_bank_account_html}" + f"{seller_bank_name_html}", + styles["Normal"], + ), + Paragraph( + f"Nabywca
{buyer_name}
" + f"NIP: {buyer_tax}
" + f"Adres: {buyer_address}" + f"{buyer_email_html}", + styles["Normal"], + ), + ] + ], + colWidths=[88 * mm, 88 * mm], + ) + + parties.setStyle(self._table_base_style()) + story.extend([parties, Spacer(1, 5 * mm)]) + + if getattr(invoice, 'split_payment', False): + story.extend([Paragraph('Mechanizm podzielonej płatności', styles['SectionTitle']), Spacer(1, 2 * mm)]) + + if nfz_meta: + nfz_rows = [ + [Paragraph("Pole NFZ", styles["Normal"]), Paragraph("Wartość", styles["Normal"])], + [Paragraph("Oddział NFZ (IDWew)", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("recipient_branch_id")), styles["Normal"])], + [Paragraph("Nazwa oddziału", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("recipient_branch_name")), styles["Normal"])], + [Paragraph("Okres rozliczeniowy od", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("settlement_from")), styles["Normal"])], + [Paragraph("Okres rozliczeniowy do", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("settlement_to")), styles["Normal"])], + [Paragraph("Identyfikator świadczeniodawcy", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("provider_identifier")), styles["Normal"])], + [Paragraph("Kod zakresu / świadczenia", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("service_code")), styles["Normal"])], + [Paragraph("Numer umowy / aneksu", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("contract_number")), styles["Normal"])], + [Paragraph("Identyfikator szablonu", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("template_identifier")), styles["Normal"])], + [Paragraph("Schemat", styles["Normal"]), Paragraph(self._safe(nfz_meta.get("nfz_schema", "FA(3)")), styles["Normal"])], + ] + nfz_table = Table(nfz_rows, colWidths=[62 * mm, 114 * mm], repeatRows=1) + nfz_table.setStyle(self._table_base_style([("ALIGN", (0, 0), (-1, 0), "CENTER")])) + story.extend([Paragraph("Dane NFZ", styles["SectionTitle"]), nfz_table, Spacer(1, 5 * mm)]) + + invoice_lines = invoice.lines.order_by("id").all() if hasattr(invoice.lines, "order_by") else list(invoice.lines) + if not invoice_lines: + invoice_lines = [ + type("TmpLine", (), line) + for line in self._extract_lines_from_xml(getattr(invoice, "xml_path", None)) + ] + + lines = [[ + Paragraph("Pozycja", styles["Normal"]), + Paragraph("Ilość", styles["Normal"]), + Paragraph("JM", styles["Normal"]), + Paragraph("VAT", styles["Normal"]), + Paragraph("Netto", styles["Normal"]), + Paragraph("Brutto", styles["Normal"]), + ]] + + if invoice_lines: + for line in invoice_lines: + lines.append([ + Paragraph(self._safe(line.description), styles["Normal"]), + Paragraph(self._safe(line.quantity), styles["Right"]), + Paragraph(self._safe(line.unit), styles["Right"]), + Paragraph(f"{Decimal(line.vat_rate):.0f}%", styles["Right"]), + Paragraph(self._money(line.net_amount, currency), styles["Right"]), + Paragraph(self._money(line.gross_amount, currency), styles["Right"]), + ]) + else: + lines.append([ + Paragraph("Brak pozycji na fakturze.", styles["Normal"]), + Paragraph("-", styles["Right"]), + Paragraph("-", styles["Right"]), + Paragraph("-", styles["Right"]), + Paragraph("-", styles["Right"]), + Paragraph("-", styles["Right"]), + ]) + + items = Table( + lines, + colWidths=[82 * mm, 18 * mm, 16 * mm, 16 * mm, 28 * mm, 30 * mm], + repeatRows=1, + ) + items.setStyle(self._table_base_style([ + ("ALIGN", (1, 0), (-1, -1), "RIGHT"), + ("ALIGN", (0, 0), (0, -1), "LEFT"), + ])) + story.extend([Paragraph("Pozycje faktury", styles["SectionTitle"]), items, Spacer(1, 5 * mm)]) + + summary = Table( + [ + [Paragraph("Netto", styles["Normal"]), Paragraph(self._money(invoice.net_amount, currency), styles["Right"])], + [Paragraph("VAT", styles["Normal"]), Paragraph(self._money(invoice.vat_amount, currency), styles["Right"])], + [Paragraph("Razem brutto", styles["Normal"]), Paragraph(f"{self._money(invoice.gross_amount, currency)}", styles["Right"])], + ], + colWidths=[48 * mm, 42 * mm], + ) + summary.setStyle(self._table_base_style([ + ("ALIGN", (0, 0), (-1, -1), "RIGHT"), + ("ALIGN", (0, 0), (0, -1), "LEFT"), + ])) + summary_wrap = Table([["", summary]], colWidths=[86 * mm, 90 * mm]) + summary_wrap.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP")])) + story.extend([summary_wrap, Spacer(1, 5 * mm)]) + + note = ( + "Dokument zawiera pola wymagane dla rozliczeń NFZ i został przygotowany do wysyłki w schemacie FA(3)." + if nfz_meta + else "Dokument wygenerowany przez KSeF Manager." + ) + story.append(Paragraph(note, styles["Small"])) + + def _apply_metadata(canvas, pdf_doc): + self._set_pdf_metadata( + canvas=canvas, + doc=pdf_doc, + title=pdf_title, + author=pdf_author, + subject=pdf_subject, + ) + + doc.build(story, onFirstPage=_apply_metadata, onLaterPages=_apply_metadata) + + pdf_bytes = buffer.getvalue() + path = Path(current_app.config["PDF_PATH"]) / f"{self._build_pdf_filename_stem(invoice)}.pdf" + path.write_bytes(pdf_bytes) + invoice.pdf_path = str(path) + return pdf_bytes, path + + def month_pdf(self, entries, title): + buffer = BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + leftMargin=16 * mm, + rightMargin=16 * mm, + topMargin=16 * mm, + bottomMargin=16 * mm, + ) + styles = self._styles() + rows = [[Paragraph("Numer", styles["Normal"]), Paragraph("Kontrahent", styles["Normal"]), Paragraph("Brutto", styles["Normal"])]] + for invoice in entries: + rows.append([ + Paragraph(str(invoice.invoice_number), styles["Normal"]), + Paragraph(str(invoice.contractor_name), styles["Normal"]), + Paragraph(self._money(invoice.gross_amount, getattr(invoice, "currency", "PLN")), styles["Right"]), + ]) + table = Table(rows, colWidths=[45 * mm, 95 * mm, 35 * mm], repeatRows=1) + table.setStyle(self._table_base_style([("ALIGN", (2, 0), (2, -1), "RIGHT")])) + + pdf_title = self._first_non_empty(title, "Zestawienie faktur") + pdf_author = "KSeF Manager" + pdf_subject = "Miesięczne zestawienie faktur" + + def _apply_metadata(canvas, pdf_doc): + self._set_pdf_metadata( + canvas=canvas, + doc=pdf_doc, + title=pdf_title, + author=pdf_author, + subject=pdf_subject, + ) + + doc.build([Paragraph(title, styles["DocTitle"]), Spacer(1, 4 * mm), table], onFirstPage=_apply_metadata, onLaterPages=_apply_metadata) + return buffer.getvalue() \ No newline at end of file diff --git a/app/services/redis_service.py b/app/services/redis_service.py new file mode 100644 index 0000000..5f7fc85 --- /dev/null +++ b/app/services/redis_service.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import json +import time +from threading import Lock +from typing import Any +from urllib.parse import urlparse + +from flask import current_app +from redis import Redis +from redis.exceptions import RedisError + + +class RedisService: + _memory_store: dict[str, tuple[float | None, str]] = {} + _lock = Lock() + _failure_logged_at = 0.0 + _availability_cache: tuple[bool, float] = (False, 0.0) + + @classmethod + def _config(cls, app=None): + if app is not None: + return app.config + return current_app.config + + @classmethod + def enabled(cls, app=None) -> bool: + cfg = cls._config(app) + return str(cfg.get('REDIS_URL', 'memory://')).strip().lower().startswith('redis://') + + @classmethod + def url(cls, app=None) -> str: + cfg = cls._config(app) + return str(cfg.get('REDIS_URL', 'memory://')).strip() or 'memory://' + + @classmethod + def _logger(cls, app=None): + if app is not None: + return app.logger + return current_app.logger + + @classmethod + def _log_failure_once(cls, action: str, exc: Exception, app=None) -> None: + now = time.time() + if now - cls._failure_logged_at < 60: + return + cls._failure_logged_at = now + cls._logger(app).warning( + 'Redis %s niedostępny, przełączam na cache pamięciowy: %s', action, exc + ) + + @classmethod + def client(cls, app=None) -> Redis | None: + if not cls.enabled(app): + return None + try: + return Redis.from_url( + cls.url(app), + decode_responses=True, + socket_connect_timeout=1, + socket_timeout=1, + ) + except Exception as exc: + cls._log_failure_once('client', exc, app) + return None + + @classmethod + def available(cls, app=None) -> bool: + if not cls.enabled(app): + return False + cached_ok, checked_at = cls._availability_cache + if time.time() - checked_at < 15: + return cached_ok + client = cls.client(app) + if client is None: + cls._availability_cache = (False, time.time()) + return False + try: + client.ping() + cls._availability_cache = (True, time.time()) + return True + except RedisError as exc: + cls._log_failure_once('ping', exc, app) + cls._availability_cache = (False, time.time()) + return False + except Exception as exc: + cls._log_failure_once('ping', exc, app) + cls._availability_cache = (False, time.time()) + return False + + @classmethod + def ping(cls, app=None) -> tuple[str, str]: + url = cls.url(app) + if not cls.enabled(app): + return 'disabled', 'Cache pamięciowy aktywny (Redis wyłączony).' + if cls.available(app): + return 'ok', f'{url} · połączenie aktywne' + parsed = urlparse(url) + hint = '' + if parsed.hostname in {'localhost', '127.0.0.1'}: + hint = ' · w Dockerze użyj nazwy usługi redis zamiast localhost' + return 'fallback', f'{url} · brak połączenia, aktywny fallback pamięciowy{hint}' + + @classmethod + def _memory_get(cls, key: str) -> Any | None: + now = time.time() + with cls._lock: + payload = cls._memory_store.get(key) + if not payload: + return None + expires_at, raw = payload + if expires_at is not None and expires_at <= now: + cls._memory_store.pop(key, None) + return None + try: + return json.loads(raw) + except Exception: + return None + + @classmethod + def _memory_set(cls, key: str, value: Any, ttl: int = 60) -> bool: + expires_at = None if ttl <= 0 else time.time() + ttl + with cls._lock: + cls._memory_store[key] = (expires_at, json.dumps(value, default=str)) + return True + + @classmethod + def get_json(cls, key: str, app=None) -> Any | None: + if not cls.enabled(app) or not cls.available(app): + return cls._memory_get(key) + client = cls.client(app) + if client is None: + return cls._memory_get(key) + try: + raw = client.get(key) + if raw: + return json.loads(raw) + return cls._memory_get(key) + except Exception as exc: + cls._log_failure_once(f'get_json({key})', exc, app) + return cls._memory_get(key) + + @classmethod + def set_json(cls, key: str, value: Any, ttl: int = 60, app=None) -> bool: + cls._memory_set(key, value, ttl=ttl) + if not cls.enabled(app) or not cls.available(app): + return False + client = cls.client(app) + if client is None: + return False + try: + client.setex(key, ttl, json.dumps(value, default=str)) + return True + except Exception as exc: + cls._log_failure_once(f'set_json({key})', exc, app) + return False + + @classmethod + def delete(cls, key: str, app=None) -> None: + with cls._lock: + cls._memory_store.pop(key, None) + if not cls.enabled(app) or not cls.available(app): + return + client = cls.client(app) + if client is None: + return + try: + client.delete(key) + except Exception as exc: + cls._log_failure_once(f'delete({key})', exc, app) \ No newline at end of file diff --git a/app/services/settings_service.py b/app/services/settings_service.py new file mode 100644 index 0000000..d3c0235 --- /dev/null +++ b/app/services/settings_service.py @@ -0,0 +1,115 @@ +from __future__ import annotations +from pathlib import Path +from flask import current_app +from app.extensions import db +from app.models.setting import AppSetting +from app.services.company_service import CompanyService + + +class SettingsService: + @staticmethod + def _user_scope_key(key: str, user_id=None): + from flask_login import current_user + if user_id is None and getattr(current_user, 'is_authenticated', False): + user_id = current_user.id + return f'user.{user_id}.{key}' if user_id else key + + @staticmethod + def _scope_key(key: str, company_id=None): + if company_id is None: + company = CompanyService.get_current_company() + company_id = company.id if company else None + return f'company.{company_id}.{key}' if company_id else key + + @staticmethod + def get(key, default=None, company_id=None): + scoped = AppSetting.get(SettingsService._scope_key(key, company_id), default=None) + if scoped is not None: + return scoped + return AppSetting.get(key, default=default) + + @staticmethod + def get_secret(key, default=None, company_id=None): + scoped = AppSetting.get(SettingsService._scope_key(key, company_id), default=None, decrypt=True) + if scoped is not None: + return scoped + return AppSetting.get(key, default=default, decrypt=True) + + @staticmethod + def set_many(mapping: dict[str, tuple[object, bool] | object], company_id=None): + for key, value in mapping.items(): + if isinstance(value, tuple): + raw, encrypt = value + else: + raw, encrypt = value, False + AppSetting.set(SettingsService._scope_key(key, company_id), raw, encrypt=encrypt) + db.session.commit() + + @staticmethod + def storage_path(key: str, fallback: Path): + value = SettingsService.get(key) + if value: + path = Path(value) + if not path.is_absolute(): + path = Path(current_app.root_path).parent / path + path.mkdir(parents=True, exist_ok=True) + return path + fallback.mkdir(parents=True, exist_ok=True) + return fallback + + @staticmethod + def read_only_enabled(company_id=None) -> bool: + from flask_login import current_user + company = CompanyService.get_current_company() + cid = company_id or (company.id if company else None) + truly_global_ro = AppSetting.get('app.read_only_mode', 'false') == 'true' + company_ro = AppSetting.get(f'company.{cid}.app.read_only_mode', 'false') == 'true' if cid else False + user_ro = getattr(current_user, 'is_authenticated', False) and cid and current_user.is_company_readonly(cid) + return truly_global_ro or company_ro or bool(user_ro) + + @staticmethod + def get_user(key, default=None, user_id=None): + return AppSetting.get(SettingsService._user_scope_key(key, user_id), default=default) + + @staticmethod + def get_user_secret(key, default=None, user_id=None): + return AppSetting.get(SettingsService._user_scope_key(key, user_id), default=default, decrypt=True) + + @staticmethod + def set_many_user(mapping: dict[str, tuple[object, bool] | object], user_id=None): + for key, value in mapping.items(): + if isinstance(value, tuple): + raw, encrypt = value + else: + raw, encrypt = value, False + AppSetting.set(SettingsService._user_scope_key(key, user_id), raw, encrypt=encrypt) + db.session.commit() + + @staticmethod + def get_preference(scope_name: str, default='global', user_id=None): + return AppSetting.get(SettingsService._user_scope_key(f'pref.{scope_name}.mode', user_id), default=default) + + @staticmethod + def set_preference(scope_name: str, mode: str, user_id=None): + AppSetting.set(SettingsService._user_scope_key(f'pref.{scope_name}.mode', user_id), mode) + db.session.commit() + + @staticmethod + def get_effective(key, default=None, company_id=None, user_id=None, scope_name=None, user_default='global'): + scope_name = scope_name or key.split('.', 1)[0] + mode = SettingsService.get_preference(scope_name, default=user_default, user_id=user_id) + if mode == 'user': + value = SettingsService.get_user(key, default=None, user_id=user_id) + if value not in [None, '']: + return value + return SettingsService.get(key, default=default, company_id=company_id) + + @staticmethod + def get_effective_secret(key, default=None, company_id=None, user_id=None, scope_name=None, user_default='global'): + scope_name = scope_name or key.split('.', 1)[0] + mode = SettingsService.get_preference(scope_name, default=user_default, user_id=user_id) + if mode == 'user': + value = SettingsService.get_user_secret(key, default=None, user_id=user_id) + if value not in [None, '']: + return value + return SettingsService.get_secret(key, default=default, company_id=company_id) diff --git a/app/services/sync_service.py b/app/services/sync_service.py new file mode 100644 index 0000000..bab63ab --- /dev/null +++ b/app/services/sync_service.py @@ -0,0 +1,129 @@ +from datetime import datetime +from threading import Thread +from flask import current_app +from app.extensions import db +from app.models.company import Company +from app.models.setting import AppSetting +from app.models.sync_log import SyncLog +from app.services.company_service import CompanyService +from app.services.invoice_service import InvoiceService +from app.services.ksef_service import KSeFService +from app.services.notification_service import NotificationService +from app.services.settings_service import SettingsService +from app.services.redis_service import RedisService + + +class SyncService: + def __init__(self, company=None): + self.company = company or CompanyService.get_current_company() + self.ksef = KSeFService(company_id=self.company.id if self.company else None) + self.invoice_service = InvoiceService() + self.notification_service = NotificationService(company_id=self.company.id if self.company else None) + + def _run(self, sync_type='manual', existing_log=None): + log = existing_log or SyncLog( + company_id=self.company.id if self.company else None, + sync_type=sync_type, + status='started', + started_at=datetime.utcnow(), + message='Rozpoczęto synchronizację', + ) + db.session.add(log) + db.session.commit() + + since_raw = SettingsService.get('ksef.last_sync_at', company_id=self.company.id if self.company else None) + since = datetime.fromisoformat(since_raw) if since_raw else None + created = updated = errors = 0 + + try: + documents = self.ksef.list_documents(since=since) + log.total = len(documents) + db.session.commit() + + for idx, document in enumerate(documents, start=1): + invoice, was_created = self.invoice_service.upsert_from_ksef(document, self.company) + if was_created: + created += 1 + self.notification_service.notify_new_invoice(invoice) + else: + updated += 1 + + log.processed = idx + log.created = created + log.updated = updated + log.message = f'Przetworzono {idx}/{len(documents)}' + db.session.commit() + + log.status = 'finished' + log.message = 'Synchronizacja zakończona' + SettingsService.set_many( + { + 'ksef.status': 'ready', + 'ksef.last_sync_at': datetime.utcnow().isoformat(), + }, + company_id=self.company.id if self.company else None, + ) + + except RuntimeError as exc: + message = str(exc) + if 'HTTP 429' in message or 'ogranicza liczbę zapytań' in message: + current_app.logger.warning('Synchronizacja KSeF wstrzymana przez limit API: %s', message) + else: + current_app.logger.error('Sync failed: %s', message) + log.status = 'error' + log.message = message + errors += 1 + SettingsService.set_many( + {'ksef.status': 'error'}, + company_id=self.company.id if self.company else None, + ) + + except Exception as exc: + current_app.logger.exception('Sync failed: %s', exc) + log.status = 'error' + log.message = str(exc) + errors += 1 + SettingsService.set_many( + {'ksef.status': 'error'}, + company_id=self.company.id if self.company else None, + ) + + RedisService.delete(f'dashboard.summary.company.{self.company.id if self.company else "global"}') + RedisService.delete(f'health.status.company.{self.company.id if self.company else "global"}') + log.errors = errors + log.finished_at = datetime.utcnow() + db.session.commit() + return log + + def run_manual_sync(self): + return self._run('manual') + + def run_scheduled_sync(self): + return self._run('scheduled') + + @staticmethod + def start_manual_sync_async(app, company_id): + company = db.session.get(Company, company_id) + log = SyncLog( + company_id=company_id, + sync_type='manual', + status='queued', + started_at=datetime.utcnow(), + message='Zadanie zakolejkowane', + total=1, + ) + db.session.add(log) + db.session.commit() + log_id = log.id + + def worker(): + with app.app_context(): + company_local = db.session.get(Company, company_id) + log_local = db.session.get(SyncLog, log_id) + log_local.status = 'started' + log_local.message = 'Start pobierania' + db.session.commit() + SyncService(company_local)._run('manual', existing_log=log_local) + + Thread(target=worker, daemon=True).start() + return log_id \ No newline at end of file diff --git a/app/services/system_data_service.py b/app/services/system_data_service.py new file mode 100644 index 0000000..e043a6c --- /dev/null +++ b/app/services/system_data_service.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import json +import os +import platform +from pathlib import Path + +import psutil +from flask import current_app +from sqlalchemy import inspect + +from app.extensions import db +from app.models.audit_log import AuditLog +from app.models.catalog import Customer, InvoiceLine, Product +from app.models.company import Company, UserCompanyAccess +from app.models.invoice import Invoice, MailDelivery, NotificationLog, SyncEvent, Tag +from app.models.setting import AppSetting +from app.models.sync_log import SyncLog +from app.models.user import User +from app.services.ceidg_service import CeidgService +from app.services.company_service import CompanyService +from app.services.health_service import HealthService +from app.services.ksef_service import KSeFService +from app.services.settings_service import SettingsService + + +class SystemDataService: + APP_MODELS = [ + ('Użytkownicy', User), + ('Firmy', Company), + ('Dostępy do firm', UserCompanyAccess), + ('Faktury', Invoice), + ('Pozycje faktur', InvoiceLine), + ('Klienci', Customer), + ('Produkty', Product), + ('Tagi', Tag), + ('Logi synchronizacji', SyncLog), + ('Zdarzenia sync', SyncEvent), + ('Wysyłki maili', MailDelivery), + ('Notyfikacje', NotificationLog), + ('Logi audytu', AuditLog), + ('Ustawienia', AppSetting), + ] + + def collect(self) -> dict: + company = CompanyService.get_current_company() + company_id = company.id if company else None + process = self._process_stats() + storage = self._storage_stats() + app = self._app_stats(company) + database = self._database_stats() + health = HealthService().get_status() + ksef = KSeFService(company_id=company_id).diagnostics() + ceidg = CeidgService().diagnostics() + return { + 'overview': self._overview_cards(process, storage, app, database, health, ksef, ceidg), + 'process': process, + 'storage': storage, + 'app': app, + 'database': database, + 'health': health, + 'integrations': { + 'ksef': ksef, + 'ceidg': ceidg, + }, + } + + def _overview_cards(self, process: dict, storage: list[dict], app: dict, database: dict, health: dict, ksef: dict, ceidg: dict) -> list[dict]: + storage_total = sum(item['size_bytes'] for item in storage) + total_records = sum(item['rows'] for item in database['table_rows']) + return [ + { + 'label': 'CPU procesu', + 'value': f"{process['cpu_percent']:.2f}%", + 'subvalue': f"PID {process['pid']} · {process['threads']} wątków", + 'icon': 'fa-microchip', + 'tone': 'primary', + }, + { + 'label': 'RAM procesu', + 'value': process['rss_human'], + 'subvalue': f"System zajęty: {process['system_memory_percent']:.2f}% z {process['system_memory_total']}", + 'icon': 'fa-memory', + 'tone': 'info', + }, + { + 'label': 'Katalogi robocze', + 'value': self._human_size(storage_total), + 'subvalue': f'{len(storage)} lokalizacji monitorowanych', + 'icon': 'fa-hard-drive', + 'tone': 'warning', + }, + { + 'label': 'Użytkownicy / firmy', + 'value': f"{app['users_count']} / {app['companies_count']}", + 'subvalue': f"R/O: {'ON' if app['read_only_global'] else 'OFF'}", + 'icon': 'fa-users', + 'tone': 'secondary', + }, + { + 'label': 'Rekordy bazy', + 'value': str(total_records), + 'subvalue': f"{database['tables_count']} tabel · {database['engine']}", + 'icon': 'fa-database', + 'tone': 'secondary', + }, + { + 'label': 'Health', + 'value': self._health_summary(health), + 'subvalue': f"DB {health.get('db')} · SMTP {health.get('smtp')} · Redis {health.get('redis')}", + 'icon': 'fa-heart-pulse', + 'tone': 'success' if health.get('db') == 'ok' and health.get('ksef') in ['ok', 'mock'] else 'warning', + }, + { + 'label': 'KSeF', + 'value': ksef.get('status', 'unknown').upper(), + 'subvalue': ksef.get('message', 'Brak danych'), + 'icon': 'fa-file-invoice', + 'tone': 'success' if ksef.get('status') in ['ok', 'mock'] else 'danger', + }, + { + 'label': 'CEIDG', + 'value': ceidg.get('status', 'unknown').upper(), + 'subvalue': ceidg.get('message', 'Brak danych'), + 'icon': 'fa-building-circle-check', + 'tone': 'success' if ceidg.get('status') == 'ok' else 'danger', + }, + ] + + def _process_stats(self) -> dict: + process = psutil.Process(os.getpid()) + cpu_percent = process.cpu_percent(interval=0.1) + mem = process.memory_info() + system_mem = psutil.virtual_memory() + try: + open_files = len(process.open_files()) + except Exception: + open_files = 0 + return { + 'pid': process.pid, + 'cpu_percent': round(cpu_percent, 2), + 'rss_bytes': int(mem.rss), + 'rss_human': self._human_size(mem.rss), + 'system_memory_total': self._human_size(system_mem.total), + 'system_memory_percent': round(system_mem.percent, 2), + 'threads': process.num_threads(), + 'open_files': open_files, + 'platform': platform.platform(), + 'python': platform.python_version(), + } + + def _storage_stats(self) -> list[dict]: + locations = [ + ('Instancja', Path(current_app.instance_path)), + ('Archiwum XML', SettingsService.storage_path('app.archive_path', current_app.config['ARCHIVE_PATH'])), + ('PDF', SettingsService.storage_path('app.pdf_path', current_app.config['PDF_PATH'])), + ('Backupy', SettingsService.storage_path('app.backup_path', current_app.config['BACKUP_PATH'])), + ('Certyfikaty', SettingsService.storage_path('app.certs_path', current_app.config['CERTS_PATH'])), + ] + rows = [] + for label, path in locations: + size_bytes = self._dir_size(path) + usage = psutil.disk_usage(str(path if path.exists() else path.parent)) + rows.append({ + 'label': label, + 'path': str(path), + 'size_bytes': size_bytes, + 'size_human': self._human_size(size_bytes), + 'disk_total': self._human_size(usage.total), + 'disk_free': self._human_size(usage.free), + 'disk_percent': round(usage.percent, 2), + }) + return rows + + def _app_stats(self, company) -> dict: + users_count = User.query.count() + companies_count = Company.query.count() + counts = [{'label': label, 'count': model.query.count()} for label, model in self.APP_MODELS] + counts_sorted = sorted(counts, key=lambda item: item['count'], reverse=True) + return { + 'current_company': company.name if company else 'Brak wybranej firmy', + 'current_company_id': company.id if company else None, + 'read_only_global': AppSetting.get('app.read_only_mode', 'false') == 'true', + 'app_timezone': current_app.config.get('APP_TIMEZONE'), + 'counts': counts_sorted, + 'counts_top': counts_sorted[:6], + 'users_count': int(users_count), + 'companies_count': int(companies_count), + } + + def _database_stats(self) -> dict: + engine = db.engine + inspector = inspect(engine) + table_names = inspector.get_table_names() + table_rows = [] + for table_name in table_names: + table = db.metadata.tables.get(table_name) + if table is None: + continue + count = db.session.execute(db.select(db.func.count()).select_from(table)).scalar() or 0 + table_rows.append({'table': table_name, 'rows': int(count)}) + uri = current_app.config.get('SQLALCHEMY_DATABASE_URI', '') + sqlite_path = None + sqlite_size = None + if uri.startswith('sqlite:///') and not uri.endswith(':memory:'): + sqlite_path = uri.replace('sqlite:///', '', 1) + try: + sqlite_size = self._human_size(Path(sqlite_path).stat().st_size) + except FileNotFoundError: + sqlite_size = 'brak pliku' + table_rows_sorted = sorted(table_rows, key=lambda item: (-item['rows'], item['table'])) + return { + 'engine': engine.name, + 'uri': self._mask_uri(uri), + 'tables_count': len(table_rows), + 'sqlite_path': sqlite_path, + 'sqlite_size': sqlite_size, + 'table_rows': table_rows_sorted, + 'largest_tables': table_rows_sorted[:6], + } + + @staticmethod + def json_preview(payload, max_len: int = 1200) -> str: + if payload is None: + return 'Brak danych.' + if isinstance(payload, str): + text = payload + else: + text = json.dumps(payload, ensure_ascii=False, indent=2, default=str) + return text if len(text) <= max_len else text[:max_len] + '\n...' + + @staticmethod + def _dir_size(path: Path) -> int: + total = 0 + if not path.exists(): + return total + if path.is_file(): + return path.stat().st_size + for root, _, files in os.walk(path): + for filename in files: + try: + total += (Path(root) / filename).stat().st_size + except OSError: + continue + return total + + @staticmethod + def _human_size(size: int | float) -> str: + value = float(size or 0) + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if value < 1024 or unit == 'TB': + return f'{value:.2f} {unit}' + value /= 1024 + return f'{value:.2f} TB' + + @staticmethod + def _mask_uri(uri: str) -> str: + if '@' in uri and '://' in uri: + prefix, suffix = uri.split('://', 1) + credentials, rest = suffix.split('@', 1) + if ':' in credentials: + user, _ = credentials.split(':', 1) + return f'{prefix}://{user}:***@{rest}' + return uri + + @staticmethod + def _health_summary(health: dict) -> str: + tracked = { + 'Baza': health.get('db'), + 'SMTP': health.get('smtp'), + 'Redis': health.get('redis'), + 'KSeF': health.get('ksef'), + 'CEIDG': health.get('ceidg'), + } + ok = sum(1 for value in tracked.values() if value in ['ok', 'mock', 'configured', 'fallback']) + total = len(tracked) + return f'{ok}/{total} OK' diff --git a/app/settings/__init__.py b/app/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/settings/routes.py b/app/settings/routes.py new file mode 100644 index 0000000..7758d0e --- /dev/null +++ b/app/settings/routes.py @@ -0,0 +1,262 @@ +import base64 + +from flask import Blueprint, flash, redirect, render_template, request, url_for +from flask_login import current_user, login_required + +from app.extensions import db +from app.forms.settings import ( + AppearanceSettingsForm, + CompanyForm, + KSeFSettingsForm, + MailSettingsForm, + NfzModuleSettingsForm, + NotificationSettingsForm, + UserForm, +) +from app.models.company import Company +from app.models.setting import AppSetting +from app.models.user import User +from app.services.company_service import CompanyService +from app.services.ksef_service import RequestsKSeFAdapter +from app.services.mail_service import MailService +from app.services.notification_service import NotificationService +from app.services.settings_service import SettingsService + + +def _can_manage_company_settings(user, company_id): + if not company_id or not getattr(user, 'is_authenticated', False): + return False + if getattr(user, 'role', '') not in {'admin', 'operator'}: + return False + return user.company_access_level(company_id) == 'full' + + +bp = Blueprint('settings', __name__, url_prefix='/settings') + + +KSEF_ENV_TO_URL = { + 'prod': RequestsKSeFAdapter.ENVIRONMENT_URLS['prod'], + 'test': RequestsKSeFAdapter.ENVIRONMENT_URLS['test'], +} + + +def _resolve_ksef_environment(company_id=None, user_id=None): + env = (SettingsService.get_effective('ksef.environment', '', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user') or '').strip().lower() + if env in KSEF_ENV_TO_URL: + return env + base_url = (SettingsService.get_effective('ksef.base_url', '', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user') or '').strip().lower() + if 'api-test.ksef.mf.gov.pl' in base_url: + return 'test' + return 'prod' + + +@bp.route('/', methods=['GET', 'POST']) +@login_required +def index(): + company = CompanyService.get_current_company() + company_id = company.id if company else None + user_id = current_user.id + company_read_only = AppSetting.get(f'company.{company_id}.app.read_only_mode', 'false') == 'true' if company_id else False + effective_read_only = SettingsService.read_only_enabled(company_id=company_id) if company_id else False + global_read_only = AppSetting.get('app.read_only_mode', 'false') == 'true' + user_read_only = bool(company_id and current_user.is_company_readonly(company_id)) + can_manage_company_settings = _can_manage_company_settings(current_user, company_id) + + ksef_mode = SettingsService.get_preference('ksef', default='user', user_id=user_id) + mail_mode = SettingsService.get_preference('mail', default='global', user_id=user_id) + notify_mode = SettingsService.get_preference('notify', default='global', user_id=user_id) + nfz_mode = SettingsService.get_preference('modules', default='global', user_id=user_id) + + ksef_form = KSeFSettingsForm( + prefix='ksef', + source_mode=ksef_mode, + environment=_resolve_ksef_environment(company_id=company_id, user_id=user_id), + auth_mode=SettingsService.get_effective('ksef.auth_mode', 'token', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user'), + client_id=SettingsService.get_effective('ksef.client_id', '', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user'), + ) + mail_form = MailSettingsForm( + prefix='mail', + source_mode=mail_mode, + server=SettingsService.get_effective('mail.server', '', company_id=company_id, user_id=user_id), + port=SettingsService.get_effective('mail.port', '587', company_id=company_id, user_id=user_id), + username=SettingsService.get_effective('mail.username', '', company_id=company_id, user_id=user_id), + sender=SettingsService.get_effective('mail.sender', '', company_id=company_id, user_id=user_id), + security_mode=(SettingsService.get_effective('mail.security_mode', '', company_id=company_id, user_id=user_id) or ('tls' if SettingsService.get_effective('mail.tls', 'true', company_id=company_id, user_id=user_id) == 'true' else 'none')), + ) + notify_form = NotificationSettingsForm( + prefix='notify', + source_mode=notify_mode, + pushover_user_key=SettingsService.get_effective('notify.pushover_user_key', '', company_id=company_id, user_id=user_id), + min_amount=SettingsService.get_effective('notify.min_amount', '0', company_id=company_id, user_id=user_id), + quiet_hours=SettingsService.get_effective('notify.quiet_hours', '', company_id=company_id, user_id=user_id), + enabled=SettingsService.get_effective('notify.enabled', 'false', company_id=company_id, user_id=user_id) == 'true', + ) + appearance_form = AppearanceSettingsForm(prefix='appearance', theme_preference=current_user.theme_preference or 'light') + nfz_form = NfzModuleSettingsForm(prefix='nfz', source_mode=nfz_mode, enabled=SettingsService.get_effective('modules.nfz_enabled', 'false', company_id=company_id, user_id=user_id) == 'true') + company_form = CompanyForm( + prefix='company', + name=company.name if company else '', + tax_id=company.tax_id if company else '', + sync_enabled=company.sync_enabled if company else False, + sync_interval_minutes=company.sync_interval_minutes if company else 60, + bank_account=company.bank_account if company else '', + read_only_mode=company_read_only, + ) + user_form = UserForm(prefix='user') + user_form.company_id.choices = [(0, '— wybierz firmę —')] + [(c.id, c.name) for c in Company.query.order_by(Company.name).all()] + + if ksef_form.submit.data and ksef_form.validate_on_submit(): + SettingsService.set_preference('ksef', ksef_form.source_mode.data, user_id=user_id) + if ksef_form.source_mode.data == 'user': + submitted_base_url = (request.form.get('ksef-base_url') or '').strip().lower() + environment = (ksef_form.environment.data or ('test' if 'api-test.ksef.mf.gov.pl' in submitted_base_url else 'prod')).lower() + if environment not in KSEF_ENV_TO_URL: + environment = 'prod' + effective_base_url = submitted_base_url or KSEF_ENV_TO_URL[environment] + data = { + 'ksef.environment': environment, + 'ksef.base_url': effective_base_url, + 'ksef.auth_mode': ksef_form.auth_mode.data, + 'ksef.client_id': (ksef_form.client_id.data or '').strip(), + } + if ksef_form.token.data: + data['ksef.token'] = (ksef_form.token.data.strip(), True) + if ksef_form.certificate_file.data: + uploaded = ksef_form.certificate_file.data + content = uploaded.read() + if content: + data['ksef.certificate_name'] = (uploaded.filename or '').strip() + data['ksef.certificate_data'] = (base64.b64encode(content).decode('ascii'), True) + SettingsService.set_many_user(data, user_id=user_id) + if company_id: + SettingsService.set_many(data, company_id=company_id) + flash('Zapisano indywidualne ustawienia KSeF.', 'success') + else: + flash('Włączono współdzielony profil KSeF dla aktywnej firmy.', 'success') + return redirect(url_for('settings.index')) + + if mail_form.submit.data and mail_form.validate_on_submit(): + SettingsService.set_preference('mail', mail_form.source_mode.data, user_id=user_id) + if mail_form.source_mode.data == 'user': + SettingsService.set_many_user({ + 'mail.server': mail_form.server.data or '', + 'mail.port': mail_form.port.data or '587', + 'mail.username': mail_form.username.data or '', + 'mail.password': (mail_form.password.data or '', True), + 'mail.sender': mail_form.sender.data or '', + 'mail.security_mode': mail_form.security_mode.data or 'tls', + 'mail.tls': str((mail_form.security_mode.data or 'tls') == 'tls').lower(), + }, user_id=user_id) + flash('Zapisano indywidualne ustawienia SMTP.', 'success') + else: + flash('Włączono globalne ustawienia SMTP.', 'success') + return redirect(url_for('settings.index')) + + if mail_form.test_submit.data and mail_form.validate_on_submit(): + SettingsService.set_preference('mail', mail_form.source_mode.data, user_id=user_id) + recipient = mail_form.test_recipient.data or current_user.email + result = MailService(company_id=company_id).send_test_mail(recipient) + flash(f'Test maila: {result["status"]}.', 'info') + return redirect(url_for('settings.index')) + + if notify_form.submit.data and notify_form.validate_on_submit(): + SettingsService.set_preference('notify', notify_form.source_mode.data, user_id=user_id) + if notify_form.source_mode.data == 'user': + SettingsService.set_many_user({ + 'notify.pushover_user_key': notify_form.pushover_user_key.data or '', + 'notify.pushover_api_token': (notify_form.pushover_api_token.data or '', True), + 'notify.min_amount': notify_form.min_amount.data or '0', + 'notify.quiet_hours': notify_form.quiet_hours.data or '', + 'notify.enabled': str(bool(notify_form.enabled.data)).lower(), + }, user_id=user_id) + flash('Zapisano indywidualne powiadomienia.', 'success') + else: + flash('Włączono globalne powiadomienia.', 'success') + return redirect(url_for('settings.index')) + + if notify_form.test_submit.data and notify_form.validate_on_submit(): + SettingsService.set_preference('notify', notify_form.source_mode.data, user_id=user_id) + log = NotificationService(company_id=company_id).send_test_pushover() + flash(f'Test Pushover: {log.status}.', 'info') + return redirect(url_for('settings.index')) + + if appearance_form.submit.data and appearance_form.validate_on_submit(): + current_user.theme_preference = appearance_form.theme_preference.data + db.session.commit() + flash('Zapisano ustawienia wyglądu.', 'success') + return redirect(url_for('settings.index')) + + if nfz_form.submit.data and nfz_form.validate_on_submit(): + SettingsService.set_preference('modules', nfz_form.source_mode.data, user_id=user_id) + if nfz_form.source_mode.data == 'user': + SettingsService.set_many_user({'modules.nfz_enabled': str(bool(nfz_form.enabled.data)).lower()}, user_id=user_id) + flash('Zapisano indywidualne ustawienia modułu NFZ.', 'success') + else: + flash('Włączono globalne ustawienia modułu NFZ.', 'success') + return redirect(url_for('settings.index')) + + if can_manage_company_settings and company_form.submit.data and company_form.validate_on_submit(): + target = company or Company() + target.name = company_form.name.data + target.tax_id = company_form.tax_id.data or '' + target.sync_enabled = bool(company_form.sync_enabled.data) + target.sync_interval_minutes = company_form.sync_interval_minutes.data or 60 + target.bank_account = (company_form.bank_account.data or '').strip() + db.session.add(target) + db.session.flush() + AppSetting.set(f'company.{target.id}.app.read_only_mode', 'true' if company_form.read_only_mode.data else 'false') + db.session.commit() + if not company: + CompanyService.assign_user(current_user, target, 'full', switch_after=True) + else: + CompanyService.switch_company(target.id) + flash('Zapisano firmę i harmonogram.', 'success') + return redirect(url_for('settings.index')) + + users = User.query.order_by(User.name).all() if current_user.role == 'admin' else [] + companies = Company.query.order_by(Company.name).all() if current_user.role == 'admin' else [] + read_only_reasons = [] + if global_read_only: + read_only_reasons.append('globalny tryb tylko odczytu') + if company_read_only: + read_only_reasons.append('blokada ustawiona dla tej firmy') + if user_read_only: + read_only_reasons.append('Twoje uprawnienia do firmy są tylko do odczytu') + certificate_name = SettingsService.get_effective('ksef.certificate_name', '', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user') + token_configured = bool(SettingsService.get_effective_secret('ksef.token', '', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user')) + certificate_configured = bool(SettingsService.get_effective_secret('ksef.certificate_data', '', company_id=company_id, user_id=user_id, scope_name='ksef', user_default='user')) + company_token_configured = bool(SettingsService.get_secret('ksef.token', '', company_id=company_id)) if company_id else False + company_certificate_name = SettingsService.get('ksef.certificate_name', '', company_id=company_id) if company_id else '' + company_certificate_configured = bool(SettingsService.get_secret('ksef.certificate_data', '', company_id=company_id)) if company_id else False + ksef_environment = _resolve_ksef_environment(company_id=company_id, user_id=user_id) + return render_template( + 'settings/index.html', + company=company, + ksef_form=ksef_form, + mail_form=mail_form, + notify_form=notify_form, + appearance_form=appearance_form, + nfz_form=nfz_form, + nfz_enabled=SettingsService.get_effective('modules.nfz_enabled', 'false', company_id=company_id, user_id=user_id) == 'true', + company_form=company_form, + user_form=user_form, + users=users, + companies=companies, + certificate_name=certificate_name, + token_configured=token_configured, + certificate_configured=certificate_configured, + company_certificate_name=company_certificate_name, + company_token_configured=company_token_configured, + company_certificate_configured=company_certificate_configured, + company_read_only=company_read_only, + effective_read_only=effective_read_only, + global_read_only=global_read_only, + user_read_only=user_read_only, + can_manage_company_settings=can_manage_company_settings, + read_only_reasons=read_only_reasons, + ksef_environment=ksef_environment, + ksef_mode=ksef_mode, + mail_mode=mail_mode, + notify_mode=notify_mode, + nfz_mode=nfz_mode, + ) diff --git a/app/static/css/app.css b/app/static/css/app.css new file mode 100644 index 0000000..72887c8 --- /dev/null +++ b/app/static/css/app.css @@ -0,0 +1,71 @@ +body { min-height: 100vh; background: var(--bs-tertiary-bg); } +pre { white-space: pre-wrap; } +html, body { overflow-x: hidden; } +.app-shell { min-height: 100vh; align-items: stretch; } +.sidebar { width: 292px; min-width: 292px; max-width: 292px; flex: 0 0 292px; min-height: 100vh; position: sticky; top: 0; overflow-y: auto; overflow-x: hidden; } +.main-column { flex: 1 1 auto; min-width: 0; width: calc(100% - 292px); } +.main-column > .p-4, .main-column section.p-4 { width: 100%; max-width: 100%; } +.page-topbar { backdrop-filter: blur(6px); } +.page-content-wrap { background: linear-gradient(180deg, rgba(13,110,253,.03), transparent 180px); } +.brand-icon { display: inline-flex; align-items: center; justify-content: center; width: 42px; height: 42px; border-radius: 14px; background: linear-gradient(135deg, #0d6efd, #6ea8fe); color: #fff; } +.menu-section-label { font-size: .75rem; text-transform: uppercase; letter-spacing: .08em; color: var(--bs-secondary-color); margin-bottom: .5rem; margin-top: 1rem; } +.nav-link { border-radius: .85rem; color: inherit; padding: .7rem .85rem; font-weight: 500; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nav-link i { width: 1.2rem; text-align: center; flex: 0 0 1.2rem; } +.nav-link:hover { background: rgba(13,110,253,.08); } +.nav-link-accent { background: rgba(13,110,253,.08); border: 1px solid rgba(13,110,253,.14); } +.nav-link-highlight { background: rgba(25,135,84,.08); border: 1px solid rgba(25,135,84,.14); } +.top-chip { padding: .35rem .65rem; border-radius: 999px; background: rgba(127,127,127,.08); } +.readonly-pill { display: inline-flex; align-items: center; padding: .4rem .75rem; border-radius: 999px; background: rgba(255,193,7,.16); color: var(--bs-emphasis-color); border: 1px solid rgba(255,193,7,.35); font-size: .875rem; } +.readonly-pill-compact { font-size: .8rem; padding: .35rem .6rem; } +.page-title { font-size: 1.15rem; } +.card { border: 0; box-shadow: 0 .35rem 1rem rgba(15, 23, 42, .08); border-radius: 1rem; } +.card-header { font-weight: 600; background: color-mix(in srgb, var(--bs-body-bg) 80%, var(--bs-primary-bg-subtle)); border-bottom: 1px solid var(--bs-border-color); border-top-left-radius: 1rem !important; border-top-right-radius: 1rem !important; } +.table thead th { font-size: .85rem; color: var(--bs-secondary-color); } +.page-section-header { padding: 1.25rem; border-radius: 1.1rem; background: var(--bs-body-bg); box-shadow: 0 .35rem 1rem rgba(15, 23, 42, .08); } +.section-eyebrow { letter-spacing: .08em; } +.section-toolbar .btn, .page-section-header .btn { border-radius: .8rem; } +.surface-muted { background: color-mix(in srgb, var(--bs-tertiary-bg) 85%, white); border-radius: 1rem; } +.settings-tab .nav-link { justify-content: flex-start; } +.settings-tab .nav-link.active { background: var(--bs-primary); color: #fff; } +.stat-card { min-height: 120px; border: 0; } +.stat-blue { background: linear-gradient(135deg, #0d6efd, #4aa3ff); } +.stat-green { background: linear-gradient(135deg, #198754, #44c28a); } +.stat-purple { background: linear-gradient(135deg, #6f42c1, #9a6bff); } +.stat-orange { background: linear-gradient(135deg, #fd7e14, #ffad5c); } +.stat-dark { background: linear-gradient(135deg, #343a40, #586069); } +.compact-card-body { padding: .95rem 1.1rem; } +.text-wrap-balanced { max-width: 46rem; } +.nfz-badge { font-size: .78rem; } +[data-bs-theme="dark"] .nav-link:hover { background: rgba(255,255,255,.08); } +[data-bs-theme="dark"] .top-chip { background: rgba(255,255,255,.06); } +[data-bs-theme="dark"] .page-content-wrap { background: linear-gradient(180deg, rgba(13,110,253,.08), transparent 200px); } +@media (max-width: 991px) { .app-shell { flex-direction: column; } .sidebar { width: 100%; min-width: 100%; max-width: 100%; flex-basis: auto; min-height: auto; position: static; } .main-column { width: 100%; } } +.invoice-detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 1rem; } +.invoice-detail-main, .invoice-detail-sidebar { min-width: 0; } +.invoice-detail-sticky { position: sticky; top: 1rem; } +.invoice-preview-surface { max-height: none; } +.invoice-preview-surface table { min-width: 720px; } +@media (max-width: 991px) { .invoice-detail-layout { grid-template-columns: 1fr; } .invoice-detail-sticky { position: static; } } +.source-switch { display: flex; flex-wrap: wrap; gap: .75rem; } +.btn-source { border: 1px solid var(--bs-border-color); border-radius: 999px; padding: .65rem 1rem; background: var(--bs-body-bg); } +.btn-check:checked + .btn-source { background: var(--bs-primary); color: #fff; border-color: var(--bs-primary); } +.source-panel { padding: 1rem; border: 1px solid var(--bs-border-color); border-radius: 1rem; background: color-mix(in srgb, var(--bs-body-bg) 92%, var(--bs-primary-bg-subtle)); } +.source-panel-note .alert { border-radius: 1rem; } +.settings-module-intro { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; margin-bottom: 1rem; padding: 1rem; border-radius: 1rem; background: color-mix(in srgb, var(--bs-tertiary-bg) 88%, var(--bs-primary-bg-subtle)); } +.login-page { background: linear-gradient(135deg, rgba(13,110,253,.08), rgba(111,66,193,.08)); } +.login-hero { align-items: center; justify-content: center; padding: 3rem; } +.login-hero-card { max-width: 34rem; } +.login-form-card { max-width: 34rem; border-radius: 1.5rem; } +.login-feature-list { display: grid; gap: 1rem; margin-top: 2rem; font-weight: 500; } +.invoice-actions-cell { min-width: 170px; } +.invoice-action-btn { min-width: 88px; height: 34px; display: inline-flex; align-items: center; justify-content: center; border-radius: .7rem; white-space: nowrap; flex: 0 0 auto; } +.table td .invoice-action-btn i { line-height: 1; } +.table td.text-end { white-space: nowrap; } +.ksef-break, td.text-break { word-break: break-word; overflow-wrap: anywhere; } +.invoice-ksef-col { min-width: 190px; max-width: 260px; } +.invoice-number-col { min-width: 180px; } +.invoice-actions-stack { display: flex; flex-wrap: nowrap; justify-content: flex-end; gap: .5rem; } +.pagination { gap: .2rem; } +.pagination .page-link { border-radius: .65rem; } +@media (max-width: 1400px) { .invoice-actions-cell { min-width: 150px; } .invoice-action-btn { min-width: 80px; padding-left: .6rem; padding-right: .6rem; } .invoice-ksef-col { min-width: 170px; max-width: 220px; } } +@media (max-width: 1200px) { .invoice-actions-stack { flex-wrap: wrap; } .table td.text-end { white-space: normal; } } diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html new file mode 100644 index 0000000..f5ebc0a --- /dev/null +++ b/app/templates/admin/_nav.html @@ -0,0 +1,21 @@ + diff --git a/app/templates/admin/admin_base.html b/app/templates/admin/admin_base.html new file mode 100644 index 0000000..07566a1 --- /dev/null +++ b/app/templates/admin/admin_base.html @@ -0,0 +1,5 @@ +{% extends 'base.html' %} +{% block content %} +{% include 'admin/_nav.html' %} +{% block admin_content %}{% endblock %} +{% endblock %} diff --git a/app/templates/admin/audit.html b/app/templates/admin/audit.html new file mode 100644 index 0000000..f1b8914 --- /dev/null +++ b/app/templates/admin/audit.html @@ -0,0 +1,5 @@ +{% extends 'admin/admin_base.html' %} +{% block title %}Audit log{% endblock %} +{% block admin_content %} +
{% for log in logs %}{% endfor %}
CzasAkcjaTypIDSzczegółyIP
{{ log.created_at }}{{ log.action }}{{ log.target_type }}{{ log.target_id or '' }}{{ log.details }}{{ log.remote_addr }}
+{% endblock %} diff --git a/app/templates/admin/companies.html b/app/templates/admin/companies.html new file mode 100644 index 0000000..188ca25 --- /dev/null +++ b/app/templates/admin/companies.html @@ -0,0 +1,6 @@ +{% extends 'admin/admin_base.html' %} +{% block title %}Firmy{% endblock %} +{% block admin_content %} +

Firmy

Oddzielne ustawienia KSeF, certyfikaty, powiadomienia i harmonogramy.
Dodaj firmę
+
{% for company in companies %}{% endfor %}
FirmaHarmonogramStatusAdres / kontoNotatka
{{ company.name }}
NIP: {{ company.tax_id or 'brak' }}{% if company.regon %} · REGON: {{ company.regon }}{% endif %}
co {{ company.sync_interval_minutes }} min {% if company.sync_enabled %}włączony{% else %}wyłączony{% endif %}{% if company.is_active %}aktywna{% else %}nieaktywna{% endif %}{{ company.address or '—' }}
{% if company.bank_account %}Konto: {{ company.bank_account }}{% endif %}
{{ company.note or '—' }} Wybierz Edytuj
+{% endblock %} diff --git a/app/templates/admin/company_form.html b/app/templates/admin/company_form.html new file mode 100644 index 0000000..c865b8a --- /dev/null +++ b/app/templates/admin/company_form.html @@ -0,0 +1,48 @@ +{% extends 'admin/admin_base.html' %} +{% block title %}{{ 'Edycja firmy' if company else 'Nowa firma' }}{% endblock %} +{% block admin_content %} +
+
+ {{ form.hidden_tag() }} +
+
+

{{ 'Edycja firmy' if company else 'Nowa firma' }}

+
Podaj NIP a następnie klikniej z Pobierz z CEIDG aby wypełnić pola.
+ +
+ {% if company %}Wybierz tę firmę{% endif %} +
+ +
+
+
+
+
+
{{ form.name.label(class='form-label') }}{{ form.name(class='form-control', placeholder='Po pobraniu z CEIDG pole uzupełni się automatycznie') }}
+
{{ form.tax_id.label(class='form-label') }}{{ form.tax_id(class='form-control', placeholder='NIP') }}
+
{{ form.fetch_submit(class='btn btn-outline-secondary btn-sm') }}
+
{{ form.regon.label(class='form-label') }}{{ form.regon(class='form-control') }}
+
{{ form.address.label(class='form-label') }}{{ form.address(class='form-control') }}
+
{{ form.bank_account.label(class='form-label') }}{{ form.bank_account(class='form-control', placeholder='np. 11 1111 1111 1111 1111 1111 1111') }}
+
{{ form.note.label(class='form-label') }}{{ form.note(class='form-control', rows='3') }}
+
+
+
+
+
+
+
+
Ustawienia
+
{{ form.is_active(class='form-check-input') }} {{ form.is_active.label(class='form-check-label') }}
+
{{ form.sync_enabled(class='form-check-input') }} {{ form.sync_enabled.label(class='form-check-label') }}
+
{{ form.mock_mode(class='form-check-input') }} {{ form.mock_mode.label(class='form-check-label') }}
+
{{ form.sync_interval_minutes.label(class='form-label') }}{{ form.sync_interval_minutes(class='form-control') }}
+
+
+
+
+ +
{{ form.submit(class='btn btn-primary') }}
+
+
+{% endblock %} diff --git a/app/templates/admin/global_settings.html b/app/templates/admin/global_settings.html new file mode 100644 index 0000000..94565a9 --- /dev/null +++ b/app/templates/admin/global_settings.html @@ -0,0 +1,15 @@ +{% extends 'admin/admin_base.html' %} +{% block title %}Ustawienia globalne{% endblock %} +{% block admin_content %} +
+

Ustawienia globalne

Wspólna konfiguracja systemu dla SMTP, Pushover i NFZ oraz model współdzielonego profilu KSeF per firma.
+
+
KSeF nie jest ustawieniem w pełni globalnym dla całego systemu. Administrator ustawia parametry domyślne oraz osobny profil współdzielony dla aktywnej firmy. Użytkownik może świadomie wybrać profil współdzielony albo własny.
+
+
SMTP globalne
{{ mail_form.hidden_tag() }}
{{ mail_form.server.label(class='form-label') }}{{ mail_form.server(class='form-control') }}
{{ mail_form.port.label(class='form-label') }}{{ mail_form.port(class='form-control') }}
{{ mail_form.username.label(class='form-label') }}{{ mail_form.username(class='form-control') }}
{{ mail_form.password.label(class='form-label') }}{{ mail_form.password(class='form-control', placeholder='Pozostaw puste aby zachować hasło') }}
{{ mail_form.sender.label(class='form-label') }}{{ mail_form.sender(class='form-control') }}
{{ mail_form.security_mode.label(class='form-label') }}{{ mail_form.security_mode(class='form-select') }}
{{ mail_form.submit(class='btn btn-primary') }}
+
Pushover globalny
{{ notify_form.hidden_tag() }}
{{ notify_form.pushover_user_key.label(class='form-label') }}{{ notify_form.pushover_user_key(class='form-control') }}
{{ notify_form.pushover_api_token.label(class='form-label') }}{{ notify_form.pushover_api_token(class='form-control', placeholder='Pozostaw puste aby zachować token') }}
{{ notify_form.min_amount.label(class='form-label') }}{{ notify_form.min_amount(class='form-control') }}
{{ notify_form.quiet_hours.label(class='form-label') }}{{ notify_form.quiet_hours(class='form-control') }}
{{ notify_form.enabled(class='form-check-input') }}{{ notify_form.enabled.label(class='form-check-label') }}
{{ notify_form.submit(class='btn btn-primary') }}
+
NFZ globalnie
{{ nfz_form.hidden_tag() }}
{{ nfz_form.enabled(class='form-check-input') }}{{ nfz_form.enabled.label(class='form-check-label') }}
{{ nfz_form.submit(class='btn btn-primary') }}
+
Domyślne parametry KSeF
{{ ksef_defaults_form.hidden_tag() }}
{{ ksef_defaults_form.environment.label(class='form-label') }}{{ ksef_defaults_form.environment(class='form-select') }}
{{ ksef_defaults_form.auth_mode.label(class='form-label') }}{{ ksef_defaults_form.auth_mode(class='form-select') }}
{{ ksef_defaults_form.client_id.label(class='form-label') }}{{ ksef_defaults_form.client_id(class='form-control') }}
Te parametry podpowiadają start nowej konfiguracji, ale nie nadpisują sekretów użytkowników ani współdzielonych profili firm.
{{ ksef_defaults_form.submit(class='btn btn-primary') }}
+
Współdzielony profil KSeF dla aktywnej firmy
Aktywna firma: {{ current_company.name if current_company else 'brak' }}. Ten profil mogą wybrać użytkownicy tej firmy zamiast własnych danych KSeF.
{{ shared_ksef_form.hidden_tag() }}
{{ shared_ksef_form.environment.label(class='form-label') }}{{ shared_ksef_form.environment(class='form-select') }}
{{ shared_ksef_form.auth_mode.label(class='form-label') }}{{ shared_ksef_form.auth_mode(class='form-select') }}
{{ shared_ksef_form.client_id.label(class='form-label') }}{{ shared_ksef_form.client_id(class='form-control') }}
{{ shared_ksef_form.certificate_name.label(class='form-label') }}{{ shared_ksef_form.certificate_name(class='form-control') }}
{{ shared_ksef_form.token.label(class='form-label') }}{{ shared_ksef_form.token(class='form-control', placeholder='Pozostaw puste aby zachować token') }}
{{ 'Token zapisany.' if shared_token_configured else 'Brak zapisanego tokena.' }}
{{ shared_ksef_form.certificate_data.label(class='form-label') }}{{ shared_ksef_form.certificate_data(class='form-control', placeholder='Pozostaw puste aby zachować certyfikat') }}
{{ 'Certyfikat zapisany.' if shared_cert_configured else 'Brak zapisanego certyfikatu.' }}
{{ shared_ksef_form.submit(class='btn btn-primary') }}
+
+{% endblock %} diff --git a/app/templates/admin/health.html b/app/templates/admin/health.html new file mode 100644 index 0000000..c2563e1 --- /dev/null +++ b/app/templates/admin/health.html @@ -0,0 +1,11 @@ +{% extends 'admin/admin_base.html' %} +{% block title %}Zdrowie systemu{% endblock %} +{% block admin_content %} +
+
Baza danych
{{ status.db }}
+
SMTP
{{ status.smtp }}
+
Redis
{{ status.redis }}
+
KSeF
{{ status.ksef }}
+
+
Status połączenia do API KSeF

{{ status.ksef_message }}

{% if status.mock_mode %}
Tryb mock jest włączony. Synchronizacja i wystawianie faktur działają lokalnie i nie wysyłają danych do produkcyjnego KSeF.
{% endif %}
+{% endblock %} diff --git a/app/templates/admin/index.html b/app/templates/admin/index.html new file mode 100644 index 0000000..4756550 --- /dev/null +++ b/app/templates/admin/index.html @@ -0,0 +1,229 @@ +{% extends 'admin/admin_base.html' %} +{% block title %}Panel admina{% endblock %} +{% block admin_content %} +
+
+

Administracja systemem

+
+ Jedno miejsce do zarządzania konfiguracją globalną, firmami, użytkownikami i danymi testowymi. +
+
+
+ +
+
+
+
+
Użytkownicy
+
{{ users }}
+
+
+
+
+
+
+
Firmy
+
{{ companies }}
+
+
+
+
+
+
+
Firmy z mock
+
{{ mock_enabled }}
+
+
+
+
+ +
+
+
+
+
+
+
Tryb tylko do odczytu
+
Globalna blokada zapisów
+
+ Szybkie przełączenie systemu między pracą operacyjną i bezpiecznym trybem tylko do odczytu. +
+
+ + {{ 'Aktywna' if global_ro else 'Wyłączona' }} + +
+ +
+
+ + + +
+ +
+ + + +
+
+
+
+
+ +
+
+
+
+
+
Dane testowe i mock
+
Środowisko demonstracyjne
+
+ Generowanie zestawu startowego i szybkie czyszczenie danych do testów prezentacyjnych. +
+
+ + {{ mock_enabled }} firm + +
+ +
+
+ + +
+ +
+ + +
+
+
+
+
+ +
+
+
+
+ API CEIDG +
+
+ +
+
+ {{ ceidg_form.hidden_tag() }} + {{ ceidg_form.environment(value='production') }} + +
+
+
+
+ + + + +
+ +
+ + PROD + +
+ +
+ Tryb produkcyjny jest używany stale. +
+
+
+ +
+
+
+ + + + +
+ +
+ + {{ 'Klucz dodany' if ceidg_api_key_configured else 'Brak klucza' }} + +
+ +
+ {% if ceidg_api_key_configured %} + Klucz jest zapisany w systemie. + {% else %} + Wprowadź klucz i zapisz formularz. + {% endif %} +
+
+
+
+ +
+
+ + +
+ {{ ceidg_form.api_key(class='form-control', autocomplete='off', placeholder='Wklej nowy token CEIDG') }} + + + +
+ +
+ Pozostaw puste, aby zachować zapisany klucz. CEIDG działa stale na PROD. +
+
+ +
+ {{ ceidg_form.submit(class='btn btn-primary px-4') }} +
+
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/maintenance.html b/app/templates/admin/maintenance.html new file mode 100644 index 0000000..469fa13 --- /dev/null +++ b/app/templates/admin/maintenance.html @@ -0,0 +1,79 @@ +{% extends 'admin/admin_base.html' %} +{% block title %}Narzędzia administracyjne{% endblock %} +{% block admin_content %} +
+
+

Logi i backupy

+
Podstrona administracyjna do porządków technicznych, kopii bazy i operacji pomocniczych.
+
+
+ +
+
+
+
+
Baza danych
+
Kopia bazy
+
Silnik
+
{{ backup_meta.engine }}
+
Katalog backupów
+
{{ backup_meta.backup_dir }}
+
+ {% if backup_meta.sqlite_supported %} + Kopia z panelu działa bezpośrednio dla SQLite. + {% else %} + Kopia z panelu nie wykonuje natywnego dumpa dla tego silnika. + {% endif %} +
+ {% if backup_meta.sqlite_path %} +
Plik SQLite
+
{{ backup_meta.sqlite_path }}
+ {% endif %} +
    + {% for note in backup_meta.notes %} +
  • {{ note }}
  • + {% endfor %} +
+
+ {{ backup_form.hidden_tag() }} + {{ backup_form.submit(class='btn btn-primary w-100') }} +
+
+
+
+ +
+
+
+
Porządki techniczne
+
Czyszczenie starych logów
+
Usuwa rekordy logów i stare rotowane pliki `.log.*` starsze niż wskazana liczba dni.
+
+ {{ cleanup_form.hidden_tag() }} +
+ {{ cleanup_form.days.label(class='form-label') }} + {{ cleanup_form.days(class='form-control') }} +
+ {{ cleanup_form.submit(class='btn btn-outline-danger w-100') }} +
+
+
+
+ +
+
+
+
Szybkie informacje
+
Podsumowanie
+
Użytkownicy{{ users }}
+
Firmy{{ companies }}
+
Logi audytu{{ audits }}
+
Mock aktywny{{ mock_enabled }}
+
Tryb R/O{{ 'ON' if global_ro else 'OFF' }}
+
+
Przy bazach innych niż SQLite przycisk backupu zapisze plik informacyjny, a właściwą kopię należy wykonać narzędziem serwera bazy.
+
+
+
+
+{% endblock %} diff --git a/app/templates/admin/reset_password.html b/app/templates/admin/reset_password.html new file mode 100644 index 0000000..09e9414 --- /dev/null +++ b/app/templates/admin/reset_password.html @@ -0,0 +1,5 @@ +{% extends 'admin/admin_base.html' %} +{% block title %}Reset hasła{% endblock %} +{% block admin_content %} +
{{ form.hidden_tag() }}

Użytkownik: {{ user.email }}

{{ form.password.label(class='form-label') }}{{ form.password(class='form-control') }}
{{ form.force_password_change(class='form-check-input') }} {{ form.force_password_change.label(class='form-check-label') }}
{{ form.submit(class='btn btn-warning') }}
+{% endblock %} diff --git a/app/templates/admin/system_data.html b/app/templates/admin/system_data.html new file mode 100644 index 0000000..b0852f3 --- /dev/null +++ b/app/templates/admin/system_data.html @@ -0,0 +1,215 @@ +{% extends 'admin/admin_base.html' %} +{% block title %}Dane systemowe{% endblock %} +{% block admin_content %} +
+
+

Dane systemowe

+
Skrócony widok techniczny: proces, health, baza, integracje i katalogi.
+
+
+ +
+
+ Redis: {{ data.health.redis|upper }} + {{ data.health.redis_details or 'brak szczegółów' }} +
+
+ +
+ {% for card in data.overview %} +
+
+
+
+
+
{{ card.label }}
+
{{ card.value }}
+
{{ card.subvalue }}
+
+ +
+
+
+
+ {% endfor %} +
+ +
+
+
+
Proces i health
+
+
+
+
+
CPU
{{ data.process.cpu_percent }}%
+
RAM
{{ data.process.rss_human }}
+
PID
{{ data.process.pid }}
+
Wątki
{{ data.process.threads }}
+
Otwarte pliki
{{ data.process.open_files }}
+
Pamięć hosta
{{ data.process.system_memory_percent }}%
+
+
+
+
+ {% set ok_values = ['ok', 'mock', 'configured', 'fallback'] %} + {% set health_items = [('Baza', data.health.db), ('SMTP', data.health.smtp), ('Redis', data.health.redis), ('KSeF', data.health.ksef), ('CEIDG', data.health.ceidg)] %} + {% for label, value in health_items %} +
+
+
{{ label }}
+
+ {{ value }} +
+
+
+ {% endfor %} +
+
+ Podsumowanie health{{ data.overview[5].value }} +
+
+
+
+
+
+
+
Python: {{ data.process.python }}
+
Platforma: {{ data.process.platform }}
+
+
+
+
+ +
+
+
Aplikacja
+
+
+
Użytkownicy
{{ data.app.users_count }}
+
Firmy
{{ data.app.companies_count }}
+
Tryb tylko do odczytu{{ 'ON' if data.app.read_only_global else 'OFF' }}
+
+
Aktywna firma
+
{{ data.app.current_company }}
+
Strefa czasowa
+
{{ data.app.app_timezone }}
+
Największe zbiory danych
+ {% for item in data.app.counts_top[:5] %} +
{{ item.label }}{{ item.count }}
+ {% endfor %} +
+
+
+
+ +
+
+
+
Baza danych
+
+
Silnik: {{ data.database.engine }}
+
Połączenie: {{ data.database.uri }}
+ {% if data.database.sqlite_path %} +
SQLite: {{ data.database.sqlite_path }} ({{ data.database.sqlite_size }})
+ {% endif %} +
Największe tabele
+ {% for item in data.database.largest_tables %} +
{{ item.table }}{{ item.rows }}
+ {% endfor %} +
+
+
+ +
+
+
Katalogi robocze
+
+
+ + + + {% for item in data.storage %} + + + + + + + {% endfor %} + +
KatalogRozmiarWolneZajęcie
{{ item.label }}
{{ item.path }}
{{ item.size_human }}{{ item.disk_free }}{{ item.disk_percent }}%
+
+
+
+
+
+ +
+
+
+
+ Połączenie KSeF + {{ data.integrations.ksef.status }} +
+
+
Komunikat: {{ data.integrations.ksef.message }}
+
Endpoint: {{ data.integrations.ksef.base_url or '—' }}
+ {% if data.integrations.ksef.auth_mode %}
Tryb autoryzacji: {{ data.integrations.ksef.auth_mode }}
{% endif %} +
+ Przykładowa odpowiedź API +
{{ json_preview(data.integrations.ksef.sample) }}
+
+
+
+
+ +
+
+
+ Połączenie CEIDG + {{ data.integrations.ceidg.status }} +
+
+
Komunikat: {{ data.integrations.ceidg.message }}
+
Tryb: {{ data.integrations.ceidg.environment }}
+
Endpoint: {{ data.integrations.ceidg.url }}
+ {% if data.integrations.ceidg.technical_details %}
Szczegóły: {{ data.integrations.ceidg.technical_details }}
{% endif %} +
+ Przykładowa odpowiedź API +
{{ json_preview(data.integrations.ceidg.sample) }}
+
+
+
+
+
+ +
+
+
+
Modele aplikacji
+
+
+ + + {% for item in data.app.counts %}{% endfor %} +
ObiektLiczba
{{ item.label }}{{ item.count }}
+
+
+
+
+
+
+
Tabele bazy
+
+
+ + + {% for item in data.database.table_rows %}{% endfor %} +
TabelaRekordy
{{ item.table }}{{ item.rows }}
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/admin/user_access.html b/app/templates/admin/user_access.html new file mode 100644 index 0000000..fdf8b9e --- /dev/null +++ b/app/templates/admin/user_access.html @@ -0,0 +1,8 @@ +{% extends 'admin/admin_base.html' %} +{% block title %}Uprawnienia: {{ user.name }}{% endblock %} +{% block admin_content %} +
+
{{ form.hidden_tag() }}
{{ form.company_id.label(class='form-label') }}{{ form.company_id(class='form-select') }}
{{ form.access_level.label(class='form-label') }}{{ form.access_level(class='form-select') }}
{{ form.submit(class='btn btn-primary') }}
+
{% for access in accesses %}{% endfor %}
FirmaDostęp
{{ access.company.name }}{{ access.access_level }}
+
+{% endblock %} diff --git a/app/templates/admin/user_form.html b/app/templates/admin/user_form.html new file mode 100644 index 0000000..d6dc627 --- /dev/null +++ b/app/templates/admin/user_form.html @@ -0,0 +1,38 @@ +{% extends 'admin/admin_base.html' %} +{% block title %}{{ 'Edycja użytkownika' if user else 'Nowy użytkownik' }}{% endblock %} +{% block admin_content %} +
+
{{ form.hidden_tag() }} +
+
+
+
Dane użytkownika
+
{{ form.name.label(class='form-label') }}{{ form.name(class='form-control') }}
+
{{ form.email.label(class='form-label') }}{{ form.email(class='form-control') }}
+
{{ form.role.label(class='form-label') }}{{ form.role(class='form-select') }}
+
{{ form.is_blocked(class='form-check-input') }} {{ form.is_blocked.label(class='form-check-label') }}
+
+
+
+
+
Hasło i dostęp startowy
+
{{ form.password.label(class='form-label') }}{{ form.password(class='form-control') }}
Pozostaw puste, aby nie zmieniać hasła.
+
{{ form.force_password_change(class='form-check-input') }} {{ form.force_password_change.label(class='form-check-label') }}
+
{{ form.company_id.label(class='form-label') }}{{ form.company_id(class='form-select') }}
+
{{ form.access_level.label(class='form-label') }}{{ form.access_level(class='form-select') }}
+
+
+ {% if user %} +
+
+
Przypisane firmy
+
{% for access in accesses %}{{ access.company.name }} / {{ access.access_level }}{% else %}Brak przypisanych firm.{% endfor %}
+ +
+
+ {% endif %} +
+
{{ form.submit(class='btn btn-primary') }}
+
+
+{% endblock %} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html new file mode 100644 index 0000000..4273700 --- /dev/null +++ b/app/templates/admin/users.html @@ -0,0 +1,9 @@ +{% extends 'admin/admin_base.html' %} +{% block title %}Użytkownicy{% endblock %} +{% block admin_content %} +

Użytkownicy

Zarządzanie kontami, blokadami i resetem hasła.
Dodaj użytkownika
+
+ +{% for user in users %}{% endfor %} +
UżytkownikRolaStatusDostęp do firm
{{ user.name }}
{{ user.email }}
{{ user.role }}{% if user.is_blocked %}zablokowany{% else %}aktywny{% endif %}{% if user.force_password_change %}zmiana hasła{% endif %}{% for access in user.company_access %}{{ access.company.name }} / {{ access.access_level }}{% else %}brak przypisanych firm{% endfor %}
+{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..fc35cd2 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,41 @@ + + + + + + + +Logowanie | KSeF Manager + + +
+
+ +
+ +
+
+
+ diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..4010eb8 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,96 @@ + + + + + + {{ app_name }} + + + + + +
+ + +
+
+
+
+
{{ current_company.name if current_company else 'Brak aktywnej firmy' }}
+ {% block title %}{% endblock %} +
+
+ {% if current_company %} +
+ + +
+ {% endif %} + {{ 'Ciemny' if theme == 'dark' else 'Jasny' }} + {% if read_only_mode %}R/O{% endif %} + +
+
+
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+
{{ global_footer_text }}
+
+
+ +{% block scripts %}{% endblock %} + + diff --git a/app/templates/dashboard/index.html b/app/templates/dashboard/index.html new file mode 100644 index 0000000..34541d4 --- /dev/null +++ b/app/templates/dashboard/index.html @@ -0,0 +1,135 @@ +{% extends 'base.html' %} +{% block title %}Dashboard{% endblock %} +{% block content %} +{% if not company %} +{% set eyebrow='Pulpit firmy' %}{% set heading='Dashboard operacyjny' %}{% set description='Podsumowanie pracy na aktywnej firmie.' %} +{% include 'partials/page_header.html' with context %} +

Brak wybranej firmy

Najpierw wybierz firmę z przełącznika w górnym pasku albo dodaj ją w panelu administracyjnym.

{{ 'Dodaj firmę' if current_user.role == 'admin' else 'Przejdź do ustawień' }}
+{% else %} +{% set eyebrow='Pulpit firmy' %}{% set heading='Dashboard operacyjny' %}{% set description='Podsumowanie bieżącego miesiąca, synchronizacji i ostatnich dokumentów.' %} +{% include 'partials/page_header.html' with context %} +
+
Faktury w miesiącu
{{ month_invoices|length }}
{{ company.name }}
+
Nowe
{{ unread }}
+
Netto
{{ totals.net|pln }}
+
VAT
{{ totals.vat|pln }}
+
Brutto
{{ totals.gross|pln }}
+
+
+
+
Synchronizacja KSeF
Status: {{ sync_status }}Ostatni sync:{{ last_sync_display }}
0%
Kliknij "Pobierz ręcznie" aby pobrać faktury z KSeF.
+
Ostatnie faktury
{% for invoice in recent_invoices %}{% else %}{% endfor %}
NumerKontrahentBrutto
{{ invoice.invoice_number }}{{ invoice.contractor_name }}{{ invoice.gross_amount|pln }}
Otwórz
{% set payment_details = payment_details_map.get(invoice.id, {}) %}{% set modal_id = 'payModalDashboard' ~ invoice.id %}{% include 'partials/payment_modal.html' %}
Brak danych.
+
+
+ {% if health.critical %} +
+
+ Raport krytyczny +
+
+ {% if health.ksef != 'ok' %} +
API KSeF: {{ health.ksef }}
+ {% if health.ksef_message %} +
{{ health.ksef_message }}
+ {% endif %} + {% endif %} + + {% if health.ceidg != 'ok' %} +
API CEIDG: {{ health.ceidg }}
+ {% if health.ceidg_message %} +
{{ health.ceidg_message }}
+ {% endif %} + {% endif %} +
+
+ {% endif %} + +
+
+ Harmonogram +
+ +
+ +
+ Automatyczna synchronizacja + + {{ 'włączona' if company.sync_enabled else 'wyłączona' }} + +
+ +
+ Interwał + + {{ company.sync_interval_minutes }} min + +
+ +
+ Tryb pracy + {% if read_only %} + tylko pobieranie + {% else %} + pełna synchronizacja + {% endif %} +
+ +
+
+ +{% endif %} +{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/403.html b/app/templates/errors/403.html new file mode 100644 index 0000000..6c433b2 --- /dev/null +++ b/app/templates/errors/403.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}403{% endblock %}{% block content %}

403

Brak uprawnień do tej operacji.

{% endblock %} diff --git a/app/templates/errors/404.html b/app/templates/errors/404.html new file mode 100644 index 0000000..31cf8f9 --- /dev/null +++ b/app/templates/errors/404.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}404{% endblock %}{% block content %}

404

Nie znaleziono strony lub zasobu.

{% endblock %} diff --git a/app/templates/errors/500.html b/app/templates/errors/500.html new file mode 100644 index 0000000..789a808 --- /dev/null +++ b/app/templates/errors/500.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}500{% endblock %}{% block content %}

500

Wystąpił błąd serwera. Spróbuj ponownie.

{% endblock %} diff --git a/app/templates/errors/503.html b/app/templates/errors/503.html new file mode 100644 index 0000000..805b436 --- /dev/null +++ b/app/templates/errors/503.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}503{% endblock %}{% block content %}

Usługa chwilowo niedostępna

{{ message or 'Usługa pomocnicza jest chwilowo niedostępna.' }}

Najczęściej oznacza to brak połączenia z Redisem. Błąd został przechwycony i nie powoduje już surowego błędu 500.

{% endblock %} diff --git a/app/templates/invoices/customers.html b/app/templates/invoices/customers.html new file mode 100644 index 0000000..1109fd0 --- /dev/null +++ b/app/templates/invoices/customers.html @@ -0,0 +1,62 @@ +{% extends 'base.html' %} +{% block title %}Kontrahenci{% endblock %} +{% block content %} +{% set eyebrow='Kartoteka' %}{% set heading='Kontrahenci' %}{% set description='Lista kontrahentów i szybka edycja danych.' %} +{% include 'partials/page_header.html' with context %} +
+
+
+
{{ 'Edytuj kontrahenta' if editing else 'Nowy kontrahent' }}
+
+
+ +
+
Do pobrania danych z CEIDG wystarczy sam NIP.
+
+
+
+
{% if editing %}Anuluj edycję{% endif %}
+
+
+
+
+
+
+
+
Baza kontrahentów
+
+
+
+
+
+
+
+ + + + {% for item in items %} + + {% else %} + + {% endfor %} + +
NazwaNIPAdresE-mail
{{ item.name }}
{{ item.regon }}
{{ item.tax_id }}{{ item.address }}{{ item.email }}Edytuj
Brak kontrahentów.
+
+
+ +
+
+
+
+{% endblock %} diff --git a/app/templates/invoices/detail.html b/app/templates/invoices/detail.html new file mode 100644 index 0000000..3e9b9d1 --- /dev/null +++ b/app/templates/invoices/detail.html @@ -0,0 +1,103 @@ +{% extends 'base.html' %} +{% block title %}Faktura{% endblock %} +{% block content %} +
+
+
+
Szczegóły faktury
+
+
+
Numer: {{ invoice.invoice_number }}
+
Numer KSeF: {{ invoice.ksef_number }}
+
Kontrahent: {{ invoice.contractor_name }}
+
NIP: {{ invoice.contractor_nip }}
+
Adres: {{ invoice.contractor_address or '—' }}
+
Kartoteka klientów: {{ linked_customer.name if linked_customer else 'brak powiązania' }}
+
Netto: {{ invoice.net_amount|pln }}
+
VAT: {{ invoice.vat_amount|pln }}
+
Brutto: {{ invoice.gross_amount|pln }}
+
Split payment: {{ 'Tak' if invoice.split_payment else 'Nie' }}
+
Forma płatności: {{ payment_details.payment_form_label or '—' }}
Rachunek bankowy: {{ payment_details.bank_account or (invoice.company.bank_account if invoice.company and invoice.source in ['issued', 'nfz'] else '') or '—' }}
{% if payment_details.bank_name %}
Bank: {{ payment_details.bank_name }}
{% endif %}{% if payment_details.payment_due_date %}
Termin płatności: {{ payment_details.payment_due_date }}
{% endif %} + {% if invoice.source in ['issued', 'nfz'] %} +
Status wystawienia: {{ invoice.issued_status_label }}
+
KSeF: {{ 'Przesłana do KSeF' if invoice.issued_to_ksef_at else 'Nieprzesłana do KSeF' }}
+ {% endif %} +
+ {% if invoice.source in ['issued', 'nfz'] and not invoice.issued_to_ksef_at %}
Ta faktura nie została jeszcze wysłana do KSeF. Możesz ją edytować i wysłać później.
{% elif invoice.source in ['issued', 'nfz'] and invoice.issued_to_ksef_at %}
Faktura została wysłana do KSeF {{ invoice.issued_to_ksef_at }}. Edycja jest zablokowana.
{% endif %} + {% if invoice.external_metadata and invoice.external_metadata.get('nfz') %}
Moduł NFZ: {{ invoice.external_metadata.get('nfz', {}).get('recipient_branch_name') }} · okres {{ invoice.external_metadata.get('nfz', {}).get('settlement_from') }} - {{ invoice.external_metadata.get('nfz', {}).get('settlement_to') }} · umowa {{ invoice.external_metadata.get('nfz', {}).get('contract_number') }}
{% endif %} +
+
{% for tag in invoice.tags %}{{ tag.name }}{% endfor %}
+
+ Pobierz PDF + {% if can_add_seller_customer %} +
+ {{ form.csrf_token }} + +
+ {% elif linked_customer %} + Otwórz kontrahenta + {% endif %} + {% if invoice.source in ['issued', 'nfz'] %} + + Duplikuj do wystawienia + + {% endif %} + {% if invoice.source == 'issued' and not invoice.issued_to_ksef_at %}Edytuj fakturę
{{ form.csrf_token }}
{% elif invoice.source == 'nfz' and not invoice.issued_to_ksef_at %}Edytuj fakturę NFZ
{{ form.csrf_token }}
{% endif %} +
+
+
+ +
+
Podgląd faktury
+
+
{{ invoice.html_preview|safe if invoice.html_preview else 'Brak podglądu HTML.' }}
+
+
+ +
+
+ Surowy XML + +
+
+
+
{{ xml_content if xml_content else 'Brak XML.' }}
+
+
+
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/invoices/index.html b/app/templates/invoices/index.html new file mode 100644 index 0000000..ae509e5 --- /dev/null +++ b/app/templates/invoices/index.html @@ -0,0 +1,149 @@ +{% extends 'base.html' %} +{% block title %}Faktury otrzymane{% endblock %} + +{% block content %} +{% set args = request.args.to_dict() %} +{% if 'page' in args %} + {% set _ = args.pop('page') %} +{% endif %} + +
+
+
+
+
Faktury otrzymane
+
Tutaj widzisz tylko dokumenty kosztowe i otrzymane od kontrahentów.
+
+ + Przejdź do wystawionych + +
+ +
+
{{ form.month(class='form-select') }}
+
{{ form.year(class='form-control', placeholder='Rok') }}
+
{{ form.contractor(class='form-control', placeholder='Kontrahent') }}
+
{{ form.nip(class='form-control', placeholder='NIP') }}
+
{{ form.invoice_type(class='form-select') }}
+
{{ form.status(class='form-select') }}
+
{{ form.quick_filter(class='form-select') }}
+
{{ form.search(class='form-control', placeholder='Szukaj') }}
+
{{ form.min_amount(class='form-control', placeholder='Min') }}
+
{{ form.max_amount(class='form-control', placeholder='Max') }}
+
{{ form.submit(class='btn btn-primary w-100') }}
+ + +
+
+
+ +
+ {{ form.csrf_token }} + +
+ {% if read_only_mode %} +
Akcje masowe są zablokowane w trybie read only.
+ {% endif %} + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + {% for invoice in pagination.items %} + + + + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
NumerKSeFKontrahentNIPDataNettoVATBruttoTypStatusAkcje
+ + {{ invoice.invoice_number }}{{ invoice.ksef_number }}{{ invoice.contractor_name }}{{ invoice.contractor_nip }}{{ invoice.issue_date }}{{ invoice.net_amount|pln }}{{ invoice.vat_amount|pln }}{{ invoice.gross_amount|pln }}{{ invoice.invoice_type_label }} + {{ invoice.status_label }} + +
+ + Otwórz + + +
+ + {% set payment_details = payment_details_map.get(invoice.id, {}) %} + {% set modal_id = 'payModalReceived' ~ invoice.id %} + {% include 'partials/payment_modal.html' %} +
Brak faktur dla wybranych filtrów.
+
+
+ +{% if pagination.pages and pagination.pages > 1 %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/invoices/issued_form.html b/app/templates/invoices/issued_form.html new file mode 100644 index 0000000..67d6c9a --- /dev/null +++ b/app/templates/invoices/issued_form.html @@ -0,0 +1,120 @@ +{% extends 'base.html' %} +{% block title %}{{ 'Edytuj fakturę' if editing_invoice else 'Wystaw fakturę' }}{% endblock %} +{% block content %} +{% set eyebrow='Sprzedaż' %}{% set heading=('Edytuj fakturę' if editing_invoice else 'Wystaw fakturę') %}{% set description='Widok uproszczony, dopasowany do stylu panelu administracyjnego.' %} +{% include 'partials/page_header.html' with context %} +
+
+
+
+ {% if read_only_mode %}
Tryb tylko do odczytu - wystawianie faktur jest zablokowane.
{% endif %} +
+ {{ form.hidden_tag() }} +
+
+
+ {{ form.customer_id.label(class='form-label mb-0') }} + +
+ {{ form.customer_id(class='form-select', disabled=read_only_mode) }} + +
+
{{ form.numbering_template.label(class='form-label') }}{{ form.numbering_template(class='form-select', disabled=read_only_mode) }}
+
{{ form.invoice_number.label(class='form-label') }}{{ form.invoice_number(class='form-control', disabled=read_only_mode, placeholder=preview_number) }}
Puste pole = numer zostanie nadany automatycznie.
+
Proponowany numer: {{ preview_number or '—' }}
+
+
+ {{ form.product_id.label(class='form-label mb-0') }} + +
+ + +
+
{{ form.quantity.label(class='form-label') }}{{ form.quantity(class='form-control', disabled=read_only_mode, id='quantityField') }}
+
{{ form.unit_net.label(class='form-label') }}{{ form.unit_net(class='form-control', disabled=read_only_mode, id='unitNetField') }}
+
+
+ {{ form.split_payment(class='form-check-input', disabled=read_only_mode, id='splitPaymentField') }} + {{ form.split_payment.label(class='form-check-label') }} +
+
Domyślnie włączane dla usług oznaczonych w kartotece. Dla faktur powyżej 15 000 PLN brutto jest wymuszane.
+
+
+
{% if editing_invoice %}{% else %}{{ form.save_submit(class='btn btn-outline-primary', disabled=read_only_mode) }}{{ form.submit(class='btn btn-primary', disabled=read_only_mode) }}{% endif %}
+
+
+
+
+
+
+
Podpowiedzi
+
+
Dodawanie klientów działa teraz przez wspólne formularze dostępne także w formularzu NFZ. (jeśli moduł włączony)
+
Po zapisaniu nowy klient lub towar zostanie automatycznie podstawiony do formularza.
+
Pełne kartoteki nadal są dostępne z linków pod polami wyboru.
+
+
+
+
+{% set quick_return_endpoint = 'invoices.issued_edit' if editing_invoice else 'invoices.issued_new' %} +{% set quick_invoice_id = editing_invoice.id if editing_invoice else None %} +{% include 'partials/invoice_quick_add_modals.html' %} + +{% endblock %} diff --git a/app/templates/invoices/issued_list.html b/app/templates/invoices/issued_list.html new file mode 100644 index 0000000..25566b2 --- /dev/null +++ b/app/templates/invoices/issued_list.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% block title %}Faktury wystawione{% endblock %} +{% block content %} +{% set eyebrow='Sprzedaż' %}{% set heading='Faktury wystawione' %}{% set description='Dokumenty sprzedażowe przygotowane w systemie, z oznaczeniem statusu wysyłki do KSeF.' %} +{% set actions %}Nowa faktura{% endset %} +{% include 'partials/page_header.html' with context %} +
+
+
{% for invoice in invoices %}{% else %}{% endfor %}
NumerKontrahentBruttoKSeFStatus
{{ invoice.invoice_number }}
{{ invoice.issue_date }}
{% if invoice.source == 'nfz' %}
NFZ
{% endif %}
{{ invoice.contractor_name }}{{ invoice.gross_amount|pln }}{% if invoice.issued_to_ksef_at %}Przesłana do KSeF{% else %}Nieprzesłana do KSeF{% endif %}
{{ invoice.ksef_number }}
{{ invoice.issued_status_label }}
Otwórz
{% set payment_details = payment_details_map.get(invoice.id, {}) %}{% set modal_id = 'payModalIssued' ~ invoice.id %}{% include 'partials/payment_modal.html' %}
Brak faktur.
+
+
+{% endblock %} diff --git a/app/templates/invoices/month_pdf.html b/app/templates/invoices/month_pdf.html new file mode 100644 index 0000000..5537b5b --- /dev/null +++ b/app/templates/invoices/month_pdf.html @@ -0,0 +1 @@ +

{{ title }}

{% for invoice in invoices %}{% endfor %}
NumerKontrahentNettoVATBrutto
{{ invoice.invoice_number }}{{ invoice.contractor_name }}{{ invoice.net_amount|pln }}{{ invoice.vat_amount|pln }}{{ invoice.gross_amount|pln }}
diff --git a/app/templates/invoices/monthly.html b/app/templates/invoices/monthly.html new file mode 100644 index 0000000..240b1eb --- /dev/null +++ b/app/templates/invoices/monthly.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} +{% block title %}Zestawienia {{ period_title }}{% endblock %} +{% block content %} +{% set eyebrow='Analiza' %}{% set heading='Zestawienia ' ~ period_title %}{% set description='Widok roczny, kwartalny i miesięczny z porównaniem do innych lat.' %} +{% set actions %}{% endset %} +{% include 'partials/page_header.html' with context %} +
Statystyka innych lat
{% for item in comparisons %}
{{ item.year }}
{{ item.count }} faktur · netto {{ item.net|pln }}
{{ item.gross|pln }}
{% if item.delta is not none %}
{{ '+' if item.delta >= 0 else '' }}{{ item.delta|pln }}
{% else %}
bazowy
{% endif %}
{% else %}
Brak danych porównawczych.
{% endfor %}
+{% for group in groups %}
{{ group.label }}{{ group.gross|pln }}
Liczba faktur: {{ group.count }}
Netto: {{ group.net|pln }}
VAT: {{ group.vat|pln }}
Brutto: {{ group.gross|pln }}
{% if period == 'month' %}{% endif %}
{% for invoice in group.entries %}{% else %}{% endfor %}
NumerKontrahentDataBrutto
{{ invoice.invoice_number }}{{ invoice.contractor_name }}{{ invoice.issue_date }}{{ invoice.gross_amount|pln }}Otwórz
Brak faktur w tym okresie.
{% else %}
Brak danych.
{% endfor %} +{% endblock %} diff --git a/app/templates/invoices/pdf.html b/app/templates/invoices/pdf.html new file mode 100644 index 0000000..266e4aa --- /dev/null +++ b/app/templates/invoices/pdf.html @@ -0,0 +1 @@ +

Faktura {{ invoice.invoice_number }}

KSeF{{ invoice.ksef_number }}
Kontrahent{{ invoice.contractor_name }}
NIP{{ invoice.contractor_nip }}
Data{{ invoice.issue_date }}
Netto{{ invoice.net_amount|pln }}
VAT{{ invoice.vat_amount|pln }}
Brutto{{ invoice.gross_amount|pln }}
Rachunek bankowy{{ invoice.seller_bank_account or (invoice.company.bank_account if invoice.company else '') or '—' }}
diff --git a/app/templates/invoices/products.html b/app/templates/invoices/products.html new file mode 100644 index 0000000..fc1c5b0 --- /dev/null +++ b/app/templates/invoices/products.html @@ -0,0 +1,65 @@ +{% extends 'base.html' %} +{% block title %}Towary i usługi{% endblock %} +{% block content %} +{% set eyebrow='Kartoteka' %}{% set heading='Towary i usługi' %}{% set description='Jednolity widok kartoteki z sortowaniem i paginacją.' %} +{% include 'partials/page_header.html' with context %} +
+
+
+
{{ 'Edytuj pozycję' if editing else 'Nowa pozycja' }}
+
+
+ +
+
+
+
+
+
+
{% if editing %}Anuluj edycję{% endif %}
+
+
+
+
+
+
+
+
Baza towarów i usług
+
+
+
+
+
+
+
+ + + + {% for item in items %} + + {% else %} + + {% endfor %} + +
NazwaSKUCena nettoVATSplit payment
{{ item.name }}{{ item.sku }}{{ item.net_price|pln }}{{ item.vat_rate }}%{% if item.split_payment_default %}Domyślny{% else %}Wyłączony{% endif %}Edytuj
Brak pozycji.
+
+
+ +
+
+
+
+{% endblock %} diff --git a/app/templates/nfz/index.html b/app/templates/nfz/index.html new file mode 100644 index 0000000..f6504aa --- /dev/null +++ b/app/templates/nfz/index.html @@ -0,0 +1,78 @@ +{% extends 'base.html' %} +{% block title %}Faktury NFZ{% endblock %} +{% block content %} +{% set editing_invoice = editing_invoice|default(None) %} +{% set eyebrow='Moduł dodatkowy' %}{% set heading='Wystawianie faktur NFZ' if not editing_invoice else 'Edycja faktury NFZ' %}{% set description='Formularz zawiera pola wymagane przez NFZ dla faktur ustrukturyzowanych FA(2)/FA(3) w KSeF.' %} +{% include 'partials/page_header.html' with context %} +
+
+
+
{{ 'Nowa faktura NFZ' if not editing_invoice else 'Edycja faktury NFZ ' ~ editing_invoice.invoice_number }}
+
+ {% if read_only_mode %}
Tryb tylko do odczytu jest aktywny. Zapisy są zablokowane.
{% endif %} +
+ {{ form.hidden_tag() }} +
+
+ {{ form.customer_id.label(class='form-label mb-0') }} + +
+ {{ form.customer_id(class='form-select', disabled=read_only_mode) }} +
+
+
+ {{ form.product_id.label(class='form-label mb-0') }} + +
+ {{ form.product_id(class='form-select', disabled=read_only_mode) }} +
+
{{ form.invoice_number.label(class='form-label') }}{{ form.invoice_number(class='form-control', disabled=read_only_mode) }}
+
{{ form.nfz_branch_id.label(class='form-label') }}{{ form.nfz_branch_id(class='form-select', disabled=read_only_mode) }}
+
{{ form.provider_identifier.label(class='form-label') }}{{ form.provider_identifier(class='form-control', disabled=read_only_mode, placeholder='id-swd') }}
+
{{ form.settlement_from.label(class='form-label') }}{{ form.settlement_from(class='form-control', disabled=read_only_mode) }}
+
{{ form.settlement_to.label(class='form-label') }}{{ form.settlement_to(class='form-control', disabled=read_only_mode) }}
+
{{ form.template_identifier.label(class='form-label') }}{{ form.template_identifier(class='form-control', disabled=read_only_mode, placeholder='id-szablonu z R_UMX') }}
+
{{ form.service_code.label(class='form-label') }}{{ form.service_code(class='form-control', disabled=read_only_mode, placeholder='02.1500.001.02/1 lub 01.0010.094.01/1/5.01.00.0000127') }}
+
{{ form.contract_number.label(class='form-label') }}{{ form.contract_number(class='form-control', disabled=read_only_mode, placeholder='120/999999/01/2025[23]') }}
+
{{ form.quantity.label(class='form-label') }}{{ form.quantity(class='form-control', disabled=read_only_mode) }}
+
{{ form.unit_net.label(class='form-label') }}{{ form.unit_net(class='form-control', disabled=read_only_mode) }}
+
{{ form.save_submit(class='btn btn-outline-primary', disabled=read_only_mode) }}{{ form.submit(class='btn btn-primary', disabled=read_only_mode) }}
+
+
+
+
+
+
+
Pola wymagane
+
+ {% for key, desc in spec_fields %} +
{{ key }}
{{ desc }}
+ {% endfor %} +
+
+
+
Ostatnie faktury NFZ
+
+
+ {% for invoice in drafts %} + + {% else %} +
Brak faktur NFZ.
+ {% endfor %} +
+
+
+
+
+{% set quick_return_endpoint = 'nfz.edit' if editing_invoice else 'nfz.index' %} +{% set quick_invoice_id = editing_invoice.id if editing_invoice else None %} +{% include 'partials/invoice_quick_add_modals.html' %} +{% endblock %} diff --git a/app/templates/notifications/index.html b/app/templates/notifications/index.html new file mode 100644 index 0000000..3365d0d --- /dev/null +++ b/app/templates/notifications/index.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} +{% block title %}Powiadomienia{% endblock %} +{% block content %} +{% set eyebrow='Log systemowy' %}{% set heading='Dziennik powiadomień' %}{% set description='Ostatnie wpisy z kanałów Pushover, maili i pozostałych integracji.' %} +{% include 'partials/page_header.html' with context %} + +{% set tone_map = {'wysłano':'success','wysłane':'success','sukces':'success','błąd':'danger','blad':'danger','pominięto':'secondary','oczekuje':'warning'} %} +
+
+ + + + + + {% for log in logs %} + {% set status = status_pl(log.status) %} + {% set badge = tone_map.get(status|lower, 'light') %} + + + + + + + {% else %} + + {% endfor %} + +
DataKanałStatusTreść
{{ log.created_at.strftime('%Y-%m-%d %H:%M') if log.created_at else '—' }}{{ channel_pl(log.channel) }}{{ status }}{{ log.message }}
Brak wpisów.
+
+
+{% endblock %} diff --git a/app/templates/partials/invoice_quick_add_modals.html b/app/templates/partials/invoice_quick_add_modals.html new file mode 100644 index 0000000..08c18a5 --- /dev/null +++ b/app/templates/partials/invoice_quick_add_modals.html @@ -0,0 +1,60 @@ +{% set quick_return_endpoint = quick_return_endpoint|default('invoices.issued_new') %} +{% set quick_invoice_id = quick_invoice_id|default(None) %} + + + + diff --git a/app/templates/partials/page_header.html b/app/templates/partials/page_header.html new file mode 100644 index 0000000..63304e8 --- /dev/null +++ b/app/templates/partials/page_header.html @@ -0,0 +1,8 @@ +
+
+
{{ eyebrow or 'Panel operacyjny' }}
+

{{ heading }}

+ {% if description %}
{{ description }}
{% endif %} +
+ {% if actions %}
{{ actions|safe }}
{% endif %} +
diff --git a/app/templates/partials/payment_modal.html b/app/templates/partials/payment_modal.html new file mode 100644 index 0000000..d4b35de --- /dev/null +++ b/app/templates/partials/payment_modal.html @@ -0,0 +1,26 @@ + diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html new file mode 100644 index 0000000..24d68d3 --- /dev/null +++ b/app/templates/settings/index.html @@ -0,0 +1,69 @@ +{% extends 'base.html' %} +{% macro source_switch(name, current, first_value, first_label, second_value, second_label) -%} +
+ + + + +
+{%- endmacro %} +{% block title %}Ustawienia{% endblock %} +{% block content %} +{% set eyebrow='Konfiguracja' %}{% set heading='Ustawienia użytkownika i firmy' %}{% set description='Wybierz, które moduły mają korzystać z profilu globalnego, a które z indywidualnych ustawień użytkownika.' %} +{% include 'partials/page_header.html' with context %} +
+
Aktywna firma
{{ company.name if company else 'Brak przypisanej firmy' }}
KSeF współdzielony dotyczy aktywnej firmy. SMTP, Pushover i NFZ mogą działać globalnie lub indywidualnie per użytkownik.
KSeF {{ ksef_environment|upper }}SMTP {{ 'global' if mail_mode == 'global' else 'indywidualny' }}Pushover {{ 'global' if notify_mode == 'global' else 'indywidualny' }}NFZ {{ 'globalny' if nfz_mode == 'global' else 'indywidualny' }}
+
Wygląd interfejsu
{{ appearance_form.hidden_tag() }}
{{ appearance_form.theme_preference.label(class='form-label') }}{{ appearance_form.theme_preference(class='form-select') }}
{{ appearance_form.submit(class='btn btn-primary') }}
+
+
+
+ +
+
+
+ {% if can_manage_company_settings %} +
Ustawienia firmy
{{ company_form.hidden_tag() }}
{{ company_form.name.label(class='form-label') }}{{ company_form.name(class='form-control') }}
{{ company_form.tax_id.label(class='form-label') }}{{ company_form.tax_id(class='form-control') }}
{{ company_form.sync_interval_minutes.label(class='form-label') }}{{ company_form.sync_interval_minutes(class='form-control') }}
{{ company_form.bank_account.label(class='form-label') }}{{ company_form.bank_account(class='form-control', placeholder='np. 11 1111 1111 1111 1111 1111 1111') }}
{{ company_form.sync_enabled(class='form-check-input') }}{{ company_form.sync_enabled.label(class='form-check-label') }}
{{ company_form.read_only_mode(class='form-check-input') }}{{ company_form.read_only_mode.label(class='form-check-label') }}
Rzeczywisty tryb może być dodatkowo ograniczony globalnie lub uprawnieniami użytkownika.
Tryb efektywny: {{ 'R/O' if effective_read_only else 'R/W' }}{% if read_only_reasons %}
Źródło: {{ read_only_reasons|join(', ') }}
{% endif %}
{{ company_form.submit(class='btn btn-primary') }}
+ {% endif %} + +
KSeF
+
Model biznesowy KSeF
Domyślnie użytkownik pracuje na własnym profilu. W razie potrzeby może przełączyć się na współdzielony profil aktywnej firmy przygotowany przez administratora.
{{ 'profil indywidualny' if ksef_mode == 'user' else 'profil współdzielony firmy' }}
+
{{ ksef_form.hidden_tag() }}{{ source_switch(ksef_form.source_mode.name, ksef_mode, 'user', 'Moje ustawienia KSeF', 'global', 'Użyj profilu współdzielonego firmy') }}
{{ ksef_form.environment.label(class='form-label') }}{{ ksef_form.environment(class='form-select') }}
{{ ksef_form.auth_mode.label(class='form-label') }}{{ ksef_form.auth_mode(class='form-select') }}
{{ ksef_form.client_id.label(class='form-label') }}{{ ksef_form.client_id(class='form-control') }}
{{ ksef_form.token.label(class='form-label') }}{{ ksef_form.token(class='form-control', autocomplete='new-password', placeholder='Podaj nowy token tylko przy zmianie') }}
{{ 'Token KSeF jest zapisany w konfiguracji tej firmy.' if company_token_configured else ('Token zapisany.' if token_configured else 'Brak zapisanego tokena.') }}
{{ ksef_form.certificate_file.label(class='form-label') }}{{ ksef_form.certificate_file(class='form-control') }}
{% if company_certificate_name %}Certyfikat KSeF jest zapisany w konfiguracji tej firmy. Wgrany plik: {{ company_certificate_name }}{% elif certificate_name %}Wgrany plik: {{ certificate_name }}{% elif company_certificate_configured %}Certyfikat KSeF jest zapisany w konfiguracji tej firmy.{% else %}Brak zapisanego certyfikatu.{% endif %}
Po zapisaniu system będzie używał współdzielonego profilu KSeF aktywnej firmy. Parametry i certyfikat konfiguruje administrator w panelu Admin → Ustawienia globalne.
{{ ksef_form.submit(class='btn btn-primary') }}
+
+ +
SMTP
{{ mail_form.hidden_tag() }}{{ source_switch(mail_form.source_mode.name, mail_mode, 'global', 'Użyj ustawień globalnych', 'user', 'Podaj indywidualne ustawienia') }}
{{ mail_form.server.label(class='form-label') }}{{ mail_form.server(class='form-control') }}
{{ mail_form.port.label(class='form-label') }}{{ mail_form.port(class='form-control') }}
{{ mail_form.username.label(class='form-label') }}{{ mail_form.username(class='form-control') }}
{{ mail_form.password.label(class='form-label') }}{{ mail_form.password(class='form-control') }}
{{ mail_form.sender.label(class='form-label') }}{{ mail_form.sender(class='form-control') }}
{{ mail_form.test_recipient.label(class='form-label') }}{{ mail_form.test_recipient(class='form-control') }}
{{ mail_form.security_mode.label(class='form-label') }}{{ mail_form.security_mode(class='form-select') }}
Przy trybie globalnym wiadomości będą wysyłane przez konfigurację ustawioną przez administratora.
{{ mail_form.submit(class='btn btn-primary') }}{{ mail_form.test_submit(class='btn btn-outline-secondary') }}
+ +
Pushover
{{ notify_form.hidden_tag() }}{{ source_switch(notify_form.source_mode.name, notify_mode, 'global', 'Użyj ustawień globalnych', 'user', 'Podaj indywidualne ustawienia') }}
{{ notify_form.pushover_user_key.label(class='form-label') }}{{ notify_form.pushover_user_key(class='form-control') }}
{{ notify_form.pushover_api_token.label(class='form-label') }}{{ notify_form.pushover_api_token(class='form-control') }}
{{ notify_form.min_amount.label(class='form-label') }}{{ notify_form.min_amount(class='form-control') }}
{{ notify_form.quiet_hours.label(class='form-label') }}{{ notify_form.quiet_hours(class='form-control') }}
{{ notify_form.enabled(class='form-check-input') }}{{ notify_form.enabled.label(class='form-check-label') }}
Przy trybie globalnym powiadomienia trafią według konfiguracji wspólnej systemu.
{{ notify_form.submit(class='btn btn-primary') }}{{ notify_form.test_submit(class='btn btn-outline-secondary') }}
+ +
Moduł NFZ
{{ nfz_form.hidden_tag() }}{{ source_switch(nfz_form.source_mode.name, nfz_mode, 'global', 'Użyj ustawień globalnych', 'user', 'Ustaw indywidualnie') }}
{{ nfz_form.enabled(class='form-check-input') }}{{ nfz_form.enabled.label(class='form-check-label') }}
Własne ustawienie użytkownika nadpisze konfigurację globalną tylko dla Twojego konta.
Moduł NFZ odziedziczy ustawienie globalne administratora.
{{ nfz_form.submit(class='btn btn-primary') }}
+
+
+
+{% endblock %} +{% block scripts %} +{{ super() }} + +{% endblock %} diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/decorators.py b/app/utils/decorators.py new file mode 100644 index 0000000..e0d4e06 --- /dev/null +++ b/app/utils/decorators.py @@ -0,0 +1,16 @@ +from functools import wraps +from flask import abort +from flask_login import current_user + + +def roles_required(*roles): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + if not current_user.is_authenticated: + abort(401) + if current_user.role not in roles: + abort(403) + return func(*args, **kwargs) + return wrapper + return decorator diff --git a/app/utils/formatters.py b/app/utils/formatters.py new file mode 100644 index 0000000..de1375f --- /dev/null +++ b/app/utils/formatters.py @@ -0,0 +1,8 @@ +from decimal import Decimal + + +def pln(value): + if value is None: + return '0,00 zł' + quantized = Decimal(value).quantize(Decimal('0.01')) + return f"{quantized:,.2f} zł".replace(',', 'X').replace('.', ',').replace('X', ' ') diff --git a/config.py b/config.py new file mode 100644 index 0000000..2cdc23e --- /dev/null +++ b/config.py @@ -0,0 +1,68 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent +load_dotenv(BASE_DIR / '.env') + + +def _normalize_sqlalchemy_db_url(raw: str | None) -> str: + if not raw: + return f"sqlite:///{(BASE_DIR / 'instance' / 'app.db').resolve()}" + if raw.startswith('sqlite:///') and not raw.startswith('sqlite:////'): + rel = raw.replace('sqlite:///', '', 1) + return f"sqlite:///{(BASE_DIR / rel).resolve()}" + return raw + + +def _path_from_env(name: str, default: Path) -> Path: + raw = os.getenv(name) + if not raw: + return default + path = Path(raw) + return path if path.is_absolute() else (BASE_DIR / path).resolve() + + +def _normalize_redis_url(raw: str | None) -> str: + if not raw: + return 'memory://' + raw = raw.strip() + if '://' not in raw: + raw = f'redis://{raw}' + return raw + + +class Config: + SECRET_KEY = os.getenv('SECRET_KEY', 'change-me-please') + APP_MASTER_KEY = os.getenv('APP_MASTER_KEY', SECRET_KEY) + SQLALCHEMY_DATABASE_URI = _normalize_sqlalchemy_db_url(os.getenv('DATABASE_URL')) + SQLALCHEMY_TRACK_MODIFICATIONS = False + ARCHIVE_PATH = _path_from_env('ARCHIVE_PATH', BASE_DIR / 'storage' / 'archive') + PDF_PATH = _path_from_env('PDF_PATH', BASE_DIR / 'storage' / 'pdf') + BACKUP_PATH = _path_from_env('BACKUP_PATH', BASE_DIR / 'storage' / 'backups') + CERTS_PATH = _path_from_env('CERTS_PATH', BASE_DIR / 'storage' / 'certs') + APP_TIMEZONE = os.getenv('APP_TIMEZONE', 'Europe/Warsaw') + LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO') + APP_PORT = int(os.getenv('APP_PORT', '5000')) + REDIS_URL = _normalize_redis_url(os.getenv('REDIS_URL', '')) + WTF_CSRF_TIME_LIMIT = None + RATELIMIT_STORAGE_URI = REDIS_URL + APP_EXTERNAL_SCHEME = os.getenv('APP_EXTERNAL_SCHEME', 'http') + APP_EXTERNAL_HOST = os.getenv('APP_EXTERNAL_HOST', '') + PREFERRED_URL_SCHEME = APP_EXTERNAL_SCHEME + SESSION_COOKIE_SECURE = APP_EXTERNAL_SCHEME == 'https' + REMEMBER_COOKIE_SECURE = APP_EXTERNAL_SCHEME == 'https' + SESSION_COOKIE_HTTPONLY = True + REMEMBER_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = 'Lax' + #CEIDG_API_URL = os.getenv('CEIDG_API_URL', 'https://dane.biznes.gov.pl/api/ceidg/v2/firmy') + #CEIDG_TEST_API_URL = os.getenv('CEIDG_TEST_API_URL', 'https://test-dane.biznes.gov.pl/api/ceidg/v2/firmy') + APP_FOOTER_TEXT = 'KSeF Manager · linuxiarz.pl · Mateusz Gruszczyński' + + +class TestConfig(Config): + TESTING = True + WTF_CSRF_ENABLED = False + SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' + REDIS_URL = 'memory://' + RATELIMIT_STORAGE_URI = 'memory://' diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..5693495 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,31 @@ +{ + auto_https off + admin off + https_port {$EXPOSE_PORT:8785} + + servers :{$EXPOSE_PORT:8785} { + protocols h1 h2 h3 + } +} + +https://{$APP_DOMAIN:localhost}:{$EXPOSE_PORT:8785} { + tls /certs/server.crt /certs/server.key + + encode gzip zstd + + request_body { + max_size 25MB + } + + header { + -Server + -Via + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "geolocation=(), microphone=(), camera=()" + Strict-Transport-Security "max-age=31536000" + } + + reverse_proxy web:5000 +} \ No newline at end of file diff --git a/deploy_docker.sh b/deploy_docker.sh new file mode 100755 index 0000000..f1e6534 --- /dev/null +++ b/deploy_docker.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env sh +set -eu + +STACK_NAME="${STACK_NAME:-ksef_app}" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" +SSL_DIR="${SSL_DIR:-./deploy/caddy/ssl}" +APP_DOMAIN="${APP_DOMAIN:-localhost}" +CERT_FILE="${CERT_FILE:-${SSL_DIR}/server.crt}" +KEY_FILE="${KEY_FILE:-${SSL_DIR}/server.key}" + +log() { + printf '%s\n' "$*" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + printf 'Brak wymaganego polecenia: %s\n' "$1" >&2 + exit 1 + } +} + +need_cmd docker +need_cmd openssl + +mkdir -p "$SSL_DIR" + +if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then + log "Nie znaleziono certyfikatu SSL w katalogu ${SSL_DIR}, tworzę self-signed cert..." + rm -f "$CERT_FILE" "$KEY_FILE" + openssl req -x509 -nodes -newkey rsa:4096 -sha256 -days 825 \ + -keyout "$KEY_FILE" \ + -out "$CERT_FILE" \ + -subj "/CN=${APP_DOMAIN}" \ + -addext "subjectAltName=DNS:${APP_DOMAIN},DNS:localhost,IP:127.0.0.1" + chmod 600 "$KEY_FILE" + chmod 644 "$CERT_FILE" +else + log "Znaleziono istniejący certyfikat SSL w katalogu ${SSL_DIR}." +fi + +log "Pobieram najnowsze obrazy bazowe..." +docker compose -f "$COMPOSE_FILE" pull + +log "Buduję obraz bez cache..." +docker compose -f "$COMPOSE_FILE" build --no-cache + +log "Zatrzymuję aktualny stack..." +docker compose -p "$STACK_NAME" -f "$COMPOSE_FILE" stop || true + +log "Usuwam osierocone kontenery i stare nieużywane obrazy..." +docker compose -p "$STACK_NAME" -f "$COMPOSE_FILE" down --remove-orphans || true +docker image prune -af || true +docker builder prune -af || true + +authoritative_stack="${STACK_NAME}" +log "Uruchamiam stack ${authoritative_stack}..." +docker compose -p "$STACK_NAME" -f "$COMPOSE_FILE" up -d + +log "Deployment zakończony. Aplikacja powinna być dostępna pod https://${APP_DOMAIN}" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6554a79 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + web: + build: . + env_file: [.env] + environment: + APP_PORT: 5000 + APP_EXTERNAL_SCHEME: https + APP_EXTERNAL_HOST: ${APP_DOMAIN:-localhost} + APP_EXTERNAL_PORT: ${EXPOSE_PORT:-8785} + TZ: ${APP_TIMEZONE:-Europe/Warsaw} + volumes: + - ./:/app + depends_on: [redis] + restart: unless-stopped + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + restart: unless-stopped + + caddy: + image: caddy:2-alpine + env_file: [.env] + ports: + - "${EXPOSE_PORT:-8785}:${EXPOSE_PORT:-8785}/tcp" + - "${EXPOSE_PORT:-8785}:${EXPOSE_PORT:-8785}/udp" + environment: + APP_DOMAIN: ${APP_DOMAIN:-ksef.local} + EXPOSE_PORT: ${EXPOSE_PORT:-8785} + volumes: + - ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./deploy/caddy/ssl:/certs:ro + - caddy_data:/data + - caddy_config:/config + depends_on: [web] + restart: unless-stopped + +volumes: + caddy_data: + caddy_config: diff --git a/migrations/README.txt b/migrations/README.txt new file mode 100644 index 0000000..7353e51 --- /dev/null +++ b/migrations/README.txt @@ -0,0 +1 @@ +Initial project migration placeholder. Use `flask --app run.py db init`, `flask --app run.py db migrate`, `flask --app run.py db upgrade` to regenerate against the current models. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..cf1468b --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = migrations + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..85c19c2 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,86 @@ +from __future__ import with_statement + +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +config = context.config + +fileConfig(config.config_file_name) +logger = fileConfig + + +def get_engine(): + try: + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace('%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + url = config.get_main_option('sqlalchemy.url') + conf_args = dict(current_app.extensions['migrate'].configure_args) + conf_args.setdefault('compare_type', True) + conf_args.setdefault('render_as_batch', True) + + context.configure( + url=url, + target_metadata=get_metadata(), + literal_binds=True, + **conf_args, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + conf_args = dict(current_app.extensions['migrate'].configure_args) + if conf_args.get('process_revision_directives') is None: + def process_revision_directives(context_, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + print('No changes in schema detected.') + conf_args['process_revision_directives'] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + conf_args.setdefault('compare_type', True) + conf_args.setdefault('render_as_batch', True) + + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/sqlite/002_add_split_payment_and_mail_security.sql b/migrations/sqlite/002_add_split_payment_and_mail_security.sql new file mode 100644 index 0000000..e0ad1cc --- /dev/null +++ b/migrations/sqlite/002_add_split_payment_and_mail_security.sql @@ -0,0 +1,9 @@ +-- SQLite migration for split payment support +-- Run once on an existing database. + +ALTER TABLE product ADD COLUMN split_payment_default INTEGER NOT NULL DEFAULT 0; +ALTER TABLE invoice ADD COLUMN split_payment INTEGER NOT NULL DEFAULT 0; + +-- Optional backfill examples: +-- UPDATE product SET split_payment_default = 1 WHERE name IN ('Usługa A', 'Usługa B'); +-- UPDATE invoice SET split_payment = 1 WHERE gross_amount >= 15000; diff --git a/migrations/sqlite/003_add_bank_account_to_company_and_invoice.sql b/migrations/sqlite/003_add_bank_account_to_company_and_invoice.sql new file mode 100644 index 0000000..0bfd284 --- /dev/null +++ b/migrations/sqlite/003_add_bank_account_to_company_and_invoice.sql @@ -0,0 +1,9 @@ +ALTER TABLE company ADD COLUMN bank_account VARCHAR(64) DEFAULT ''; +ALTER TABLE invoice ADD COLUMN seller_bank_account VARCHAR(64) DEFAULT ''; + +-- Optional backfill from current company data for already issued invoices: +-- UPDATE invoice +-- SET seller_bank_account = ( +-- SELECT company.bank_account FROM company WHERE company.id = invoice.company_id +-- ) +-- WHERE COALESCE(seller_bank_account, '') = ''; diff --git a/migrations/versions/001_add_contractor_regon_and_address_to_invoice.py b/migrations/versions/001_add_contractor_regon_and_address_to_invoice.py new file mode 100644 index 0000000..1df3ce6 --- /dev/null +++ b/migrations/versions/001_add_contractor_regon_and_address_to_invoice.py @@ -0,0 +1,50 @@ +"""add contractor regon and address to invoice + +Revision ID: add_invoice_contractor_fields +Revises: +Create Date: 2026-03-10 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +revision = "add_invoice_contractor_fields" +down_revision = None +branch_labels = None +depends_on = None + + +def _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + return column_name in {col["name"] for col in inspector.get_columns(table_name)} + + +def _has_index(table_name: str, index_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + return index_name in {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def upgrade(): + with op.batch_alter_table("invoice", schema=None) as batch_op: + if not _has_column("invoice", "contractor_regon"): + batch_op.add_column(sa.Column("contractor_regon", sa.String(length=32), nullable=True)) + if not _has_column("invoice", "contractor_address"): + batch_op.add_column(sa.Column("contractor_address", sa.String(length=512), nullable=True)) + index_name = batch_op.f("ix_invoice_contractor_regon") + if _has_column("invoice", "contractor_regon") and not _has_index("invoice", index_name): + batch_op.create_index(index_name, ["contractor_regon"], unique=False) + + +def downgrade(): + with op.batch_alter_table("invoice", schema=None) as batch_op: + index_name = batch_op.f("ix_invoice_contractor_regon") + if _has_index("invoice", index_name): + batch_op.drop_index(index_name) + if _has_column("invoice", "contractor_address"): + batch_op.drop_column("contractor_address") + if _has_column("invoice", "contractor_regon"): + batch_op.drop_column("contractor_regon") diff --git a/migrations/versions/002_add_split_payment_and_mail_security.py b/migrations/versions/002_add_split_payment_and_mail_security.py new file mode 100644 index 0000000..9daf8e1 --- /dev/null +++ b/migrations/versions/002_add_split_payment_and_mail_security.py @@ -0,0 +1,42 @@ +"""add split payment and mail security + +Revision ID: 002_add_split_payment_and_mail_security +Revises: add_invoice_contractor_fields +Create Date: 2026-03-12 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +revision = '002_add_split_payment_and_mail_security' +down_revision = 'add_invoice_contractor_fields' +branch_labels = None +depends_on = None + + +def _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + return column_name in {col["name"] for col in inspector.get_columns(table_name)} + + +def upgrade(): + with op.batch_alter_table('product') as batch_op: + if not _has_column('product', 'split_payment_default'): + batch_op.add_column(sa.Column('split_payment_default', sa.Boolean(), nullable=False, server_default=sa.false())) + + with op.batch_alter_table('invoice') as batch_op: + if not _has_column('invoice', 'split_payment'): + batch_op.add_column(sa.Column('split_payment', sa.Boolean(), nullable=False, server_default=sa.false())) + + +def downgrade(): + with op.batch_alter_table('invoice') as batch_op: + if _has_column('invoice', 'split_payment'): + batch_op.drop_column('split_payment') + + with op.batch_alter_table('product') as batch_op: + if _has_column('product', 'split_payment_default'): + batch_op.drop_column('split_payment_default') diff --git a/migrations/versions/003_add_bank_account_to_company_and_invoice.py b/migrations/versions/003_add_bank_account_to_company_and_invoice.py new file mode 100644 index 0000000..adf8811 --- /dev/null +++ b/migrations/versions/003_add_bank_account_to_company_and_invoice.py @@ -0,0 +1,44 @@ +"""add bank account to company and invoice + +Revision ID: 003_add_bank_account_to_company_and_invoice +Revises: 002_add_split_payment_and_mail_security +Create Date: 2026-03-12 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +revision = '003_add_bank_account_to_company_and_invoice' +down_revision = '002_add_split_payment_and_mail_security' +branch_labels = None +depends_on = None + + +def _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + return column_name in {col['name'] for col in inspector.get_columns(table_name)} + + +def upgrade(): + with op.batch_alter_table('company') as batch_op: + if not _has_column('company', 'bank_account'): + batch_op.add_column(sa.Column('bank_account', sa.String(length=64), nullable=True, server_default='')) + + with op.batch_alter_table('invoice') as batch_op: + if not _has_column('invoice', 'seller_bank_account'): + batch_op.add_column(sa.Column('seller_bank_account', sa.String(length=64), nullable=True, server_default='')) + + op.execute("UPDATE invoice SET seller_bank_account = COALESCE(seller_bank_account, '')") + op.execute("UPDATE company SET bank_account = COALESCE(bank_account, '')") + + +def downgrade(): + with op.batch_alter_table('invoice') as batch_op: + if _has_column('invoice', 'seller_bank_account'): + batch_op.drop_column('seller_bank_account') + + with op.batch_alter_table('company') as batch_op: + if _has_column('company', 'bank_account'): + batch_op.drop_column('bank_account') diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6b06c51 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Migrate==4.0.7 +Flask-Login==0.6.3 +Flask-WTF==1.2.2 +Flask-Mail==0.10.0 +Flask-Limiter==3.12 +email-validator==2.2.0 +python-dotenv==1.0.1 +SQLAlchemy==2.0.38 +alembic==1.14.1 +APScheduler==3.10.4 +requests==2.32.3 +redis==5.2.1 +rq==2.1.0 +psycopg==3.2.6 +pytest==8.3.5 +pytest-flask==1.3.0 +factory-boy==3.3.3 +Faker==36.1.1 +cryptography==45.0.0 +xhtml2pdf==0.2.17 +psutil +gunicorn==23.0.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..d5db31d --- /dev/null +++ b/run.py @@ -0,0 +1,7 @@ +from app import create_app +from config import Config + +app = create_app() + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=Config.APP_PORT) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8919a69 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +import pytest +from werkzeug.security import generate_password_hash +from app import create_app +from config import TestConfig +from app.extensions import db +from app.models.user import User +from app.models.company import Company, UserCompanyAccess +from app.models.setting import AppSetting + + +@pytest.fixture() +def app(): + app = create_app(TestConfig) + with app.app_context(): + db.create_all() + company = Company(name='Test Co', tax_id='123', sync_enabled=True, sync_interval_minutes=60) + db.session.add(company) + db.session.flush() + user = User(email='admin@example.com', name='Admin', password_hash=generate_password_hash('Admin123!'), role='admin') + db.session.add(user) + db.session.flush() + db.session.add(UserCompanyAccess(user_id=user.id, company_id=company.id, access_level='full')) + AppSetting.set(f'company.{company.id}.notify.enabled', 'true') + AppSetting.set(f'company.{company.id}.notify.min_amount', '0') + AppSetting.set(f'company.{company.id}.ksef.mock_mode', 'true') + db.session.commit() + yield app + db.drop_all() + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def auth_client(client): + client.post('/auth/login', data={'email': 'admin@example.com', 'password': 'Admin123!'}, follow_redirects=True) + return client diff --git a/tests/test_admin_nav.py b/tests/test_admin_nav.py new file mode 100644 index 0000000..9726142 --- /dev/null +++ b/tests/test_admin_nav.py @@ -0,0 +1,22 @@ +def test_admin_nav_visible_on_users(auth_client): + response = auth_client.get('/admin/users') + body = response.get_data(as_text=True) + assert response.status_code == 200 + assert 'Logi audytu' in body + assert 'Firmy' in body + + +def test_admin_nav_visible_on_companies(auth_client): + response = auth_client.get('/admin/companies') + body = response.get_data(as_text=True) + assert response.status_code == 200 + assert 'Użytkownicy' in body + assert 'Logi audytu' in body + + +def test_admin_nav_visible_on_audit(auth_client): + response = auth_client.get('/admin/audit') + body = response.get_data(as_text=True) + assert response.status_code == 200 + assert 'Użytkownicy' in body + assert 'Firmy' in body diff --git a/tests/test_admin_system.py b/tests/test_admin_system.py new file mode 100644 index 0000000..9dbd377 --- /dev/null +++ b/tests/test_admin_system.py @@ -0,0 +1,187 @@ +from datetime import datetime, timedelta +from pathlib import Path + +from requests import exceptions as requests_exceptions + +from app.extensions import db +from app.models.audit_log import AuditLog +from app.models.company import Company +from app.models.invoice import MailDelivery, NotificationLog, SyncEvent +from app.models.setting import AppSetting +from app.models.sync_log import SyncLog +from app.services.ksef_service import KSeFService, RequestsKSeFAdapter + + +class DummyResponse: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + self.content = b'{}' + self.text = '{}' + + def json(self): + return self._payload + + +def test_admin_system_data_page(auth_client, monkeypatch): + from app.services.ceidg_service import CeidgService + + monkeypatch.setattr(CeidgService, 'diagnostics', lambda self: { + 'status': 'ok', + 'message': 'HTTP 200', + 'environment': 'test', + 'url': 'https://test.example/ceidg', + 'sample': {'ok': True}, + 'technical_details': None, + }) + + response = auth_client.get('/admin/system-data') + body = response.get_data(as_text=True) + assert response.status_code == 200 + assert 'Dane systemowe' in body + assert 'Połączenie KSeF' in body + assert 'Połączenie CEIDG' in body + assert 'Proces i health systemu' in body + assert 'Użytkownicy' in body + assert 'Diagnoza' not in body + assert 'Co sprawdzić' not in body + + +def test_admin_health_redirects_to_system_data(auth_client): + response = auth_client.get('/admin/health', follow_redirects=False) + assert response.status_code == 302 + assert '/admin/system-data' in response.headers['Location'] + + +def test_ksef_diagnostics_mock(app): + with app.app_context(): + company = Company.query.first() + data = KSeFService(company_id=company.id).diagnostics() + assert data['status'] == 'mock' + assert data['base_url'] == 'mock://ksef' + assert 'documentExample' in data['sample'] + + +def test_ksef_diagnostics_requests_adapter(app, monkeypatch): + with app.app_context(): + company = Company.query.first() + AppSetting.set(f'company.{company.id}.ksef.mock_mode', 'false') + db.session.commit() + + monkeypatch.setattr(RequestsKSeFAdapter, '_request', lambda self, method, path, params=None, json=None: {'status': 'healthy', 'version': 'demo'}) + + data = KSeFService(company_id=company.id).diagnostics() + assert data['status'] == 'ok' + assert data['sample']['status'] == 'healthy' + assert data['base_url'].startswith('https://') + + +def test_ceidg_diagnostics_timeout(app, monkeypatch): + from app.services.ceidg_service import CeidgService + + def raise_timeout(*args, **kwargs): + raise requests_exceptions.ConnectTimeout('connect timeout') + + monkeypatch.setattr('app.services.ceidg_service.requests.get', raise_timeout) + + with app.app_context(): + AppSetting.set('ceidg.environment', 'test') + AppSetting.set('ceidg.api_key', 'diagnostic-key', encrypt=True) + db.session.commit() + data = CeidgService().diagnostics() + assert data['status'] == 'error' + assert 'Timeout' in data['message'] + assert data['environment'] == 'test' + + +def test_cleanup_logs_removes_old_records(app, auth_client): + with app.app_context(): + old_dt = datetime.utcnow() - timedelta(days=120) + company = Company.query.first() + invoice = company.invoices.first() + if invoice is None: + from app.models.invoice import Invoice, InvoiceType, InvoiceStatus + invoice = Invoice(company_id=company.id, ksef_number='X1', invoice_number='FV/1', contractor_name='Test', issue_date=old_dt.date(), net_amount=1, vat_amount=0.23, gross_amount=1.23, invoice_type=InvoiceType.SALE, status=InvoiceStatus.NEW) + db.session.add(invoice) + db.session.flush() + db.session.add(AuditLog(action='old', target_type='system', created_at=old_dt, updated_at=old_dt)) + db.session.add(SyncLog(sync_type='manual', status='done', started_at=old_dt, company_id=company.id, created_at=old_dt, updated_at=old_dt)) + db.session.add(NotificationLog(invoice_id=invoice.id, channel='mail', status='done', created_at=old_dt, updated_at=old_dt)) + db.session.add(MailDelivery(invoice_id=invoice.id, recipient='a@example.com', status='sent', created_at=old_dt, updated_at=old_dt)) + db.session.add(SyncEvent(invoice_id=invoice.id, status='ok', created_at=old_dt, updated_at=old_dt)) + db.session.commit() + + response = auth_client.post('/admin/logs/cleanup', data={'days': 90}, follow_redirects=True) + assert response.status_code == 200 + body = response.get_data(as_text=True) + assert 'Usunięto stare logi starsze niż 90 dni' in body + + with app.app_context(): + assert AuditLog.query.filter_by(action='old').count() == 0 + assert SyncLog.query.filter_by(sync_type='manual').count() == 0 + assert NotificationLog.query.count() == 0 + assert MailDelivery.query.count() == 0 + + +def test_database_backup_download(auth_client, app): + response = auth_client.post('/admin/database/backup') + assert response.status_code == 200 + assert 'attachment;' in response.headers.get('Content-Disposition', '') + assert 'db_backup_' in response.headers.get('Content-Disposition', '') + + with app.app_context(): + backup_dir = Path(app.config['BACKUP_PATH']) + assert any(path.name.startswith('db_backup_') for path in backup_dir.iterdir()) + + +def test_clear_mock_data_removes_legacy_mock_invoices(app, auth_client): + from app.models.invoice import Invoice, InvoiceStatus, InvoiceType + + with app.app_context(): + company = Company.query.first() + company_id = company.id + rows = [ + Invoice(company_id=company_id, ksef_number='KSEF/MOCK/C1/2026/10001', invoice_number='FV/1/001/2026', contractor_name='Firma 1-1', issue_date=datetime.utcnow().date(), net_amount=1, vat_amount=0.23, gross_amount=1.23, invoice_type=InvoiceType.SALE, status=InvoiceStatus.NEW, source='ksef', issued_status='received', external_metadata={'source': 'mock', 'sequence': 1, 'company_id': company_id}), + Invoice(company_id=company_id, ksef_number='KSEF/MOCK/C1/2026/10002', invoice_number='FV/1/002/2026', contractor_name='Firma 1-2', issue_date=datetime.utcnow().date(), net_amount=1, vat_amount=0.23, gross_amount=1.23, invoice_type=InvoiceType.SALE, status=InvoiceStatus.NEW, source='ksef', issued_status='received', external_metadata={'source': 'mock', 'sequence': 2, 'company_id': company_id}), + Invoice(company_id=company_id, ksef_number='KSEF/MOCK/C1/2026/10003', invoice_number='FV/1/003/2026', contractor_name='Firma 1-3', issue_date=datetime.utcnow().date(), net_amount=1, vat_amount=0.23, gross_amount=1.23, invoice_type=InvoiceType.SALE, status=InvoiceStatus.NEW, source='ksef', issued_status='received', external_metadata={'source': 'mock', 'sequence': 3, 'company_id': company_id}), + Invoice(company_id=company_id, ksef_number='KSEF/MOCK/C1/2026/10004', invoice_number='FV/1/004/2026', contractor_name='Firma 1-4', issue_date=datetime.utcnow().date(), net_amount=1, vat_amount=0.23, gross_amount=1.23, invoice_type=InvoiceType.SALE, status=InvoiceStatus.NEW, source='ksef', issued_status='received', external_metadata={'source': 'mock', 'sequence': 4, 'company_id': company_id}), + Invoice(company_id=company_id, ksef_number='KSEF/MOCK/C1/2026/10005', invoice_number='FV/1/005/2026', contractor_name='Firma 1-5', issue_date=datetime.utcnow().date(), net_amount=1, vat_amount=0.23, gross_amount=1.23, invoice_type=InvoiceType.SALE, status=InvoiceStatus.NEW, source='ksef', issued_status='received', external_metadata={'source': 'mock', 'sequence': 5, 'company_id': company_id}), + Invoice(company_id=company_id, ksef_number='PENDING/FV/2026/03/0002', invoice_number='FV/2026/03/0002', contractor_name='aaaa', issue_date=datetime.utcnow().date(), net_amount=1, vat_amount=0.23, gross_amount=1.23, invoice_type=InvoiceType.SALE, status=InvoiceStatus.NEW, source='issued', issued_status='draft', external_metadata={}), + Invoice(company_id=company_id, ksef_number='NFZ-PENDING/FV/2026/03/0003', invoice_number='FV/2026/03/0003', contractor_name='NFZ', issue_date=datetime.utcnow().date(), net_amount=1, vat_amount=0.23, gross_amount=1.23, invoice_type=InvoiceType.SALE, status=InvoiceStatus.NEW, source='nfz', issued_status='draft', external_metadata={'nfz': {'x': 1}}), + Invoice(company_id=company_id, ksef_number='KSEF/MOCK/ISSUED/C1/2026/1773052148', invoice_number='FV/2026/03/0004', contractor_name='aaaa', issue_date=datetime.utcnow().date(), net_amount=1, vat_amount=0.23, gross_amount=1.23, invoice_type=InvoiceType.SALE, status=InvoiceStatus.NEW, source='issued', issued_status='issued_mock', external_metadata={}), + ] + db.session.add_all(rows) + AppSetting.set(f'company.{company_id}.ksef.mock_mode', 'true') + db.session.commit() + + assert Invoice.query.count() == 8 + assert Invoice.query.filter(Invoice.ksef_number.like('%MOCK%')).count() == 6 + + response = auth_client.post('/admin/mock-data/clear', follow_redirects=True) + assert response.status_code == 200 + body = response.get_data(as_text=True) + assert 'Usunięto dane mock: faktury 6' in body + + with app.app_context(): + remaining_numbers = [row[0] for row in db.session.query(Invoice.invoice_number).order_by(Invoice.id).all()] + assert remaining_numbers == ['FV/2026/03/0002', 'FV/2026/03/0003'] + assert AppSetting.get(f'company.{company_id}.ksef.mock_mode') == 'false' + + +def test_save_ceidg_settings_persists_api_key(app, auth_client): + long_api_key = 'A1B2C3D4' * 32 + + response = auth_client.post( + '/admin/ceidg/save', + data={'environment': 'test', 'api_key': long_api_key}, + follow_redirects=True, + ) + + assert response.status_code == 200 + with app.app_context(): + assert AppSetting.get('ceidg.environment') == 'test' + assert AppSetting.get('ceidg.api_key', decrypt=True) == long_api_key + stored = AppSetting.query.filter_by(key='ceidg.api_key').first() + assert stored is not None + assert stored.is_encrypted is True + assert stored.value != long_api_key diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..f358865 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,17 @@ +from app.models.company import Company +from app.services.sync_service import SyncService + + +def test_health(client): + response = client.get('/api/health') + assert response.status_code == 200 + assert response.json['db'] == 'ok' + + +def test_api_invoices(auth_client, app): + with app.app_context(): + company = Company.query.first() + SyncService(company).run_manual_sync() + response = auth_client.get('/api/invoices') + assert response.status_code == 200 + assert 'items' in response.json diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..02420b0 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,10 @@ +def test_login_page(client): + response = client.get('/auth/login') + assert response.status_code == 200 + assert b'Logowanie' in response.data + + +def test_login_success(client): + response = client.post('/auth/login', data={'email': 'admin@example.com', 'password': 'Admin123!'}, follow_redirects=True) + assert response.status_code == 200 + assert b'Dashboard' in response.data diff --git a/tests/test_ceidg_service.py b/tests/test_ceidg_service.py new file mode 100644 index 0000000..0a78c6b --- /dev/null +++ b/tests/test_ceidg_service.py @@ -0,0 +1,68 @@ +import json + +from app.models.setting import AppSetting +from app.services.ceidg_service import CeidgService + + +def test_ceidg_service_parses_warehouse_payload(app): + payload = { + 'firmy': [ + { + 'id': '9D2531B1-6DED-4538-95EA-22FF2C7D2E20', + 'nazwa': 'Adam IntegracjaMGMF', + 'adresDzialalnosci': { + 'ulica': 'ul. Zwierzyniecka', + 'budynek': '1', + 'miasto': 'Białystok', + 'kod': '15-333', + }, + 'wlasciciel': { + 'imie': 'Adam', + 'nazwa': 'IntegracjaMGMF', + 'nip': '3563457932', + 'regon': '518155359', + }, + } + ] + } + + with app.app_context(): + parsed = CeidgService()._parse_payload(json.dumps(payload), '3563457932') + + assert parsed == { + 'name': 'Adam IntegracjaMGMF', + 'regon': '518155359', + 'address': 'ul. Zwierzyniecka 1, 15-333 Białystok', + 'tax_id': '3563457932', + } + + +def test_ceidg_service_uses_bearer_authorization(app): + with app.app_context(): + AppSetting.set('ceidg.api_key', 'jwt-token', encrypt=True) + headers = CeidgService._headers() + + assert headers['Authorization'] == 'Bearer jwt-token' + + +def test_admin_company_fetch_from_ceidg_requires_only_nip(app, auth_client, monkeypatch): + lookup = {'ok': True, 'name': 'Test CEIDG', 'tax_id': '1234567890', 'regon': '123456789', 'address': 'Warszawa'} + monkeypatch.setattr('app.admin.routes.CeidgService.fetch_company', lambda self, identifier=None, **kwargs: lookup) + + response = auth_client.post( + '/admin/companies/new', + data={ + 'name': '', + 'tax_id': '1234567890', + 'regon': '', + 'address': '', + 'sync_interval_minutes': '60', + 'fetch_submit': '1', + }, + follow_redirects=True, + ) + + body = response.get_data(as_text=True) + assert response.status_code == 200 + assert 'Pobrano dane firmy z CEIDG.' in body + assert 'Test CEIDG' in body diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..6ba5d9e --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,45 @@ +from datetime import date, datetime, timedelta + +from app.extensions import db +from app.models.company import Company +from app.models.invoice import Invoice, InvoiceStatus, InvoiceType + + +def test_dashboard_recent_invoices_uses_10_items_per_page(auth_client, app): + with app.app_context(): + company = Company.query.first() + now = datetime.utcnow() + for index in range(12): + invoice = Invoice( + company_id=company.id, + ksef_number=f'KSEF-DASH-{index}', + invoice_number=f'FV/DASH/{index}', + contractor_name=f'Kontrahent {index}', + issue_date=date(2026, 3, 12) - timedelta(days=index), + received_date=date(2026, 3, 12) - timedelta(days=index), + net_amount=100 + index, + vat_amount=23, + gross_amount=123 + index, + invoice_type=InvoiceType.PURCHASE, + status=InvoiceStatus.NEW, + source='ksef', + created_at=now - timedelta(minutes=index), + ) + db.session.add(invoice) + db.session.commit() + + response_page_1 = auth_client.get('/') + assert response_page_1.status_code == 200 + assert response_page_1.data.count(b'btn btn-outline-primary">Szczeg') == 10 + assert b'FV/DASH/0' in response_page_1.data + assert b'FV/DASH/9' in response_page_1.data + assert b'FV/DASH/10' not in response_page_1.data + assert b'FV/DASH/11' not in response_page_1.data + assert b'dashboard_page=2' in response_page_1.data + + response_page_2 = auth_client.get('/?dashboard_page=2') + assert response_page_2.status_code == 200 + assert response_page_2.data.count(b'btn btn-outline-primary">Szczeg') == 2 + assert b'FV/DASH/10' in response_page_2.data + assert b'FV/DASH/11' in response_page_2.data + assert b'FV/DASH/9' not in response_page_2.data diff --git a/tests/test_invoices.py b/tests/test_invoices.py new file mode 100644 index 0000000..6abd4b6 --- /dev/null +++ b/tests/test_invoices.py @@ -0,0 +1,160 @@ +from app.models.company import Company +from app.models.invoice import Invoice +from app.services.sync_service import SyncService + + +def test_invoice_list(auth_client, app): + with app.app_context(): + company = Company.query.first() + SyncService(company).run_manual_sync() + response = auth_client.get('/invoices/') + assert response.status_code == 200 + assert b'Faktury' in response.data + + +def test_invoice_pdf(auth_client, app): + with app.app_context(): + company = Company.query.first() + SyncService(company).run_manual_sync() + invoice = Invoice.query.first() + response = auth_client.get(f'/invoices/{invoice.id}/pdf') + assert response.status_code == 200 + assert response.mimetype == 'application/pdf' + + +def test_issued_form_uses_quick_add_modals(auth_client): + response = auth_client.get('/invoices/issued/new') + assert response.status_code == 200 + assert b'customerQuickAddModal' in response.data + assert b'productQuickAddModal' in response.data + assert b'Szybkie dodanie klienta' not in response.data + + +def test_invoice_list_shows_only_incoming(auth_client, app): + from datetime import date + from app.extensions import db + from app.models.company import Company + from app.models.invoice import Invoice, InvoiceStatus, InvoiceType + + with app.app_context(): + company = Company.query.first() + sale = Invoice( + company_id=company.id, + ksef_number='SALE-HIDDEN', + invoice_number='FV/SALE/1', + contractor_name='Sprzedaz Sp z o.o.', + issue_date=date(2026, 3, 1), + received_date=date(2026, 3, 1), + net_amount=100, + vat_amount=23, + gross_amount=123, + invoice_type=InvoiceType.SALE, + status=InvoiceStatus.NEW, + source='issued', + issued_status='draft', + ) + purchase = Invoice( + company_id=company.id, + ksef_number='PURCHASE-VISIBLE', + invoice_number='FV/PUR/1', + contractor_name='Dostawca Sp z o.o.', + issue_date=date(2026, 3, 1), + received_date=date(2026, 3, 1), + net_amount=200, + vat_amount=46, + gross_amount=246, + invoice_type=InvoiceType.PURCHASE, + status=InvoiceStatus.NEW, + source='ksef', + ) + db.session.add_all([sale, purchase]) + db.session.commit() + + response = auth_client.get('/invoices/') + assert response.status_code == 200 + assert b'FV/PUR/1' in response.data + assert b'FV/SALE/1' not in response.data + assert b'Faktury otrzymane' in response.data + + + +def test_resolve_payment_details_reads_metadata_and_xml(app, tmp_path): + from datetime import date + from app.extensions import db + from app.models.company import Company + from app.models.invoice import Invoice, InvoiceStatus, InvoiceType + from app.services.invoice_service import InvoiceService + + xml_path = tmp_path / 'invoice.xml' + xml_path.write_text(''' + + + 6 + + 12 3456 7890 1234 5678 9012 3456 + Bank Testowy + + + 2026-03-31 + + +''', encoding='utf-8') + + with app.app_context(): + company = Company.query.first() + invoice = Invoice( + company_id=company.id, + ksef_number='KSEF/TEST/1', + invoice_number='FV/TEST/1', + contractor_name='Dostawca', + issue_date=date(2026, 3, 12), + received_date=date(2026, 3, 12), + net_amount=100, + vat_amount=23, + gross_amount=123, + invoice_type=InvoiceType.PURCHASE, + status=InvoiceStatus.NEW, + source='ksef', + xml_path=str(xml_path), + external_metadata={'payment_form_label': 'przelew'}, + ) + db.session.add(invoice) + db.session.commit() + + details = InvoiceService().resolve_payment_details(invoice) + + assert details['payment_form_code'] == '6' + assert details['payment_form_label'] == 'przelew' + assert details['bank_account'] == '12345678901234567890123456' + assert details['bank_name'] == 'Bank Testowy' + assert details['payment_due_date'] == '2026-03-31' + + +def test_purchase_invoice_does_not_fallback_to_company_bank_account(app): + from datetime import date + from app.extensions import db + from app.models.company import Company + from app.models.invoice import Invoice, InvoiceStatus, InvoiceType + from app.services.invoice_service import InvoiceService + + with app.app_context(): + company = Company.query.first() + company.bank_account = '11 1111 1111 1111 1111 1111 1111' + invoice = Invoice( + company_id=company.id, + ksef_number='KSEF/TEST/2', + invoice_number='FV/TEST/2', + contractor_name='Dostawca', + issue_date=date(2026, 3, 12), + received_date=date(2026, 3, 12), + net_amount=100, + vat_amount=23, + gross_amount=123, + invoice_type=InvoiceType.PURCHASE, + status=InvoiceStatus.NEW, + source='ksef', + ) + db.session.add(invoice) + db.session.commit() + + assert InvoiceService()._resolve_seller_bank_account(invoice) == '' diff --git a/tests/test_ksef_xml_generator.py b/tests/test_ksef_xml_generator.py new file mode 100644 index 0000000..6334eba --- /dev/null +++ b/tests/test_ksef_xml_generator.py @@ -0,0 +1,125 @@ +from datetime import date +import xml.etree.ElementTree as ET + +from app.models.catalog import InvoiceLine +from app.models.company import Company +from app.models.invoice import Invoice, InvoiceType +from app.services.invoice_service import InvoiceService + + +NS = {'fa': 'http://crd.gov.pl/wzor/2025/06/25/13775/'} + + +def test_render_structured_xml_uses_fa3_schema_and_split_payment(app): + with app.app_context(): + company = Company.query.first() + company.name = 'Test Co Sp. z o.o.' + company.tax_id = '5250000001' + company.address = 'ul. Testowa 1, 00-001 Warszawa' + company.bank_account = '11 1111 1111 1111 1111 1111 1111' + + invoice = Invoice( + company_id=company.id, + company=company, + ksef_number='DRAFT/1', + invoice_number='FV/1/2026', + contractor_name='Klient Sp. z o.o.', + contractor_nip='5260000002', + contractor_address='ul. Odbiorcy 2, 00-002 Warszawa', + issue_date=date(2026, 3, 12), + net_amount=100, + vat_amount=23, + gross_amount=123, + invoice_type=InvoiceType.SALE, + split_payment=True, + currency='PLN', + seller_bank_account='11 1111 1111 1111 1111 1111 1111', + source='issued', + external_metadata={}, + ) + lines = [ + InvoiceLine( + description='Usługa abonamentowa', + quantity=1, + unit='usł.', + unit_net=100, + vat_rate=23, + net_amount=100, + vat_amount=23, + gross_amount=123, + ) + ] + + xml = InvoiceService().render_structured_xml(invoice, lines=lines, nfz_meta={}) + root = ET.fromstring(xml) + + assert root.tag == '{http://crd.gov.pl/wzor/2025/06/25/13775/}Faktura' + assert root.findtext('fa:Naglowek/fa:KodFormularza', namespaces=NS) == 'FA' + kod = root.find('fa:Naglowek/fa:KodFormularza', NS) + assert kod is not None + assert kod.attrib['kodSystemowy'] == 'FA (3)' + assert kod.attrib['wersjaSchemy'] == '1-0E' + assert root.findtext('fa:Naglowek/fa:WariantFormularza', namespaces=NS) == '3' + assert root.findtext('fa:Fa/fa:P_2', namespaces=NS) == 'FV/1/2026' + assert root.findtext('fa:Fa/fa:Adnotacje/fa:P_18A', namespaces=NS) == '1' + assert root.findtext('fa:Fa/fa:P_13_1', namespaces=NS) == '100.00' + assert root.findtext('fa:Fa/fa:P_14_1', namespaces=NS) == '23.00' + assert root.findtext('fa:Fa/fa:P_15', namespaces=NS) == '123.00' + assert root.findtext('fa:Fa/fa:Platnosc/fa:FormaPlatnosci', namespaces=NS) == '6' + assert root.findtext('fa:Fa/fa:Platnosc/fa:RachunekBankowy/fa:NrRB', namespaces=NS) == '11111111111111111111111111' + assert root.findtext('fa:Fa/fa:FaWiersz/fa:P_8A', namespaces=NS) == 'usł.' + assert root.findtext('fa:Fa/fa:FaWiersz/fa:P_8B', namespaces=NS) == '1' + + +def test_render_structured_xml_maps_nfz_metadata_to_dodatkowy_opis(app): + with app.app_context(): + company = Company.query.first() + invoice = Invoice( + company_id=company.id, + company=company, + ksef_number='DRAFT/2', + invoice_number='FV/NFZ/1/2026', + contractor_name='NFZ', + contractor_nip='1234567890', + issue_date=date(2026, 3, 12), + net_amount=200, + vat_amount=46, + gross_amount=246, + invoice_type=InvoiceType.SALE, + split_payment=False, + currency='PLN', + source='nfz', + external_metadata={}, + ) + lines = [ + InvoiceLine( + description='Świadczenie medyczne', + quantity=2, + unit='usł.', + unit_net=100, + vat_rate=23, + net_amount=200, + vat_amount=46, + gross_amount=246, + ) + ] + nfz_meta = { + 'recipient_branch_id': '01', + 'settlement_from': '2026-03-01', + 'settlement_to': '2026-03-31', + 'provider_identifier': 'NFZ-ABC', + 'service_code': 'SVC-1', + 'contract_number': 'UM-2026-01', + 'template_identifier': 'TPL-77', + } + + xml = InvoiceService().render_structured_xml(invoice, lines=lines, nfz_meta=nfz_meta) + root = ET.fromstring(xml) + + dodatki = root.findall('fa:Fa/fa:DodatkowyOpis', NS) + pairs = {item.findtext('fa:Klucz', namespaces=NS): item.findtext('fa:Wartosc', namespaces=NS) for item in dodatki} + assert pairs['IDWew'] == '01' + assert pairs['P_6_Od'] == '2026-03-01' + assert pairs['P_6_Do'] == '2026-03-31' + assert pairs['NrUmowy'] == 'UM-2026-01' + assert root.findtext('fa:Podmiot3/fa:DaneIdentyfikacyjne/fa:IDWew', namespaces=NS) == '01' diff --git a/tests/test_nfz.py b/tests/test_nfz.py new file mode 100644 index 0000000..33ee9a1 --- /dev/null +++ b/tests/test_nfz.py @@ -0,0 +1,35 @@ +from decimal import Decimal + +from app.extensions import db +from app.models.catalog import Customer, Product +from app.models.company import Company +from app.models.setting import AppSetting + + +def test_nfz_index_loads_with_module_enabled(auth_client, app): + with app.app_context(): + company = Company.query.first() + AppSetting.set(f'company.{company.id}.modules.nfz_enabled', 'true') + db.session.add(Customer(company_id=company.id, name='NFZ Client', tax_id='1070001057', is_active=True)) + db.session.add(Product(company_id=company.id, name='Swiadczenie', unit='usl', net_price=Decimal('100.00'), vat_rate=Decimal('8.00'), is_active=True)) + db.session.commit() + + response = auth_client.get('/nfz/') + assert response.status_code == 200 + assert b'NFZ' in response.data + + +def test_nfz_index_uses_shared_quick_add_modals(auth_client, app): + with app.app_context(): + company = Company.query.first() + AppSetting.set(f'company.{company.id}.modules.nfz_enabled', 'true') + if not Customer.query.filter_by(company_id=company.id, name='NFZ Modal Client').first(): + db.session.add(Customer(company_id=company.id, name='NFZ Modal Client', tax_id='1070001057', is_active=True)) + if not Product.query.filter_by(company_id=company.id, name='NFZ Modal Service').first(): + db.session.add(Product(company_id=company.id, name='NFZ Modal Service', unit='usl', net_price=Decimal('50.00'), vat_rate=Decimal('8.00'), is_active=True)) + db.session.commit() + response = auth_client.get('/nfz/') + assert response.status_code == 200 + assert b'customerQuickAddModal' in response.data + assert b'productQuickAddModal' in response.data + assert b'Szybkie dodanie klienta' not in response.data diff --git a/tests/test_nfz_production.py b/tests/test_nfz_production.py new file mode 100644 index 0000000..cb7b1ef --- /dev/null +++ b/tests/test_nfz_production.py @@ -0,0 +1,99 @@ +from decimal import Decimal + +from app.extensions import db +from app.models.catalog import Customer, Product, InvoiceLine +from app.models.company import Company +from app.models.invoice import Invoice, InvoiceStatus, InvoiceType +from app.models.setting import AppSetting +from app.services.invoice_service import InvoiceService + + +def _enable_nfz(app): + with app.app_context(): + company = Company.query.first() + AppSetting.set(f'company.{company.id}.modules.nfz_enabled', 'true') + customer = Customer.query.filter_by(company_id=company.id, tax_id='1070001057').first() + if not customer: + customer = Customer(company_id=company.id, name='NFZ Client', tax_id='1070001057', is_active=True) + db.session.add(customer) + product = Product.query.filter_by(company_id=company.id, name='Swiadczenie NFZ').first() + if not product: + product = Product(company_id=company.id, name='Swiadczenie NFZ', unit='usl', net_price=Decimal('100.00'), vat_rate=Decimal('8.00'), is_active=True) + db.session.add(product) + db.session.commit() + return company.id, customer.id, product.id + + +def test_duplicate_nfz_redirects_to_nfz_form(auth_client, app): + company_id, customer_id, product_id = _enable_nfz(app) + with app.app_context(): + invoice = Invoice( + company_id=company_id, + customer_id=customer_id, + ksef_number='NFZ-PENDING/1', + invoice_number='FV/NFZ/1', + contractor_name='Narodowy Fundusz Zdrowia - Slaski OW NFZ', + contractor_nip='1070001057', + issue_date=InvoiceService.today_date(), + received_date=InvoiceService.today_date(), + net_amount=Decimal('100.00'), + vat_amount=Decimal('8.00'), + gross_amount=Decimal('108.00'), + invoice_type=InvoiceType.SALE, + status=InvoiceStatus.NEW, + source='nfz', + issued_status='draft', + external_metadata={'nfz': {'recipient_branch_id': '1070001057-00122', 'settlement_from': '2026-02-01', 'settlement_to': '2026-02-28', 'template_identifier': 'TMP', 'provider_identifier': 'SWD1', 'service_code': '01.02', 'contract_number': 'C/1', 'nfz_schema': 'FA(3)'}}, + ) + db.session.add(invoice) + db.session.flush() + db.session.add(InvoiceLine(invoice_id=invoice.id, product_id=product_id, description='Swiadczenie NFZ', quantity=1, unit='usl', unit_net=Decimal('100.00'), vat_rate=Decimal('8.00'), net_amount=Decimal('100.00'), vat_amount=Decimal('8.00'), gross_amount=Decimal('108.00'))) + db.session.commit() + invoice_id = invoice.id + + response = auth_client.get(f'/invoices/{invoice_id}/duplicate', follow_redirects=False) + assert response.status_code == 302 + assert f'/nfz/?duplicate_id={invoice_id}' in response.headers['Location'] + + form_response = auth_client.get(response.headers['Location']) + body = form_response.get_data(as_text=True) + assert form_response.status_code == 200 + assert 'FV/NFZ/1/COPY' in body + assert 'SWD1' in body + assert 'C/1' in body + + +def test_build_ksef_payload_contains_nfz_xml(app): + company_id, customer_id, product_id = _enable_nfz(app) + with app.app_context(): + company = Company.query.first() + invoice = Invoice( + company_id=company_id, + company=company, + customer_id=customer_id, + ksef_number='NFZ-PENDING/2', + invoice_number='FV/NFZ/2', + contractor_name='Narodowy Fundusz Zdrowia - Slaski OW NFZ', + contractor_nip='1070001057', + issue_date=InvoiceService.today_date(), + received_date=InvoiceService.today_date(), + net_amount=Decimal('100.00'), + vat_amount=Decimal('8.00'), + gross_amount=Decimal('108.00'), + invoice_type=InvoiceType.SALE, + status=InvoiceStatus.NEW, + source='nfz', + issued_status='draft', + external_metadata={'nfz': {'recipient_branch_id': '1070001057-00122', 'settlement_from': '2026-02-01', 'settlement_to': '2026-02-28', 'template_identifier': 'TMP', 'provider_identifier': 'SWD1', 'service_code': '01.02', 'contract_number': 'C/1', 'nfz_schema': 'FA(3)'}}, + ) + db.session.add(invoice) + db.session.flush() + db.session.add(InvoiceLine(invoice_id=invoice.id, product_id=product_id, description='Swiadczenie NFZ', quantity=1, unit='usl', unit_net=Decimal('100.00'), vat_rate=Decimal('8.00'), net_amount=Decimal('100.00'), vat_amount=Decimal('8.00'), gross_amount=Decimal('108.00'))) + db.session.commit() + + payload = InvoiceService().build_ksef_payload(invoice) + assert payload['schemaVersion'] == 'FA(3)' + assert '1070001057-00122' in payload['xml_content'] + assert '2026-02-01' in payload['xml_content'] + assert '2026-02-28' in payload['xml_content'] + assert 'NrUmowy' in payload['xml_content'] diff --git a/tests/test_settings_ksef.py b/tests/test_settings_ksef.py new file mode 100644 index 0000000..fe38089 --- /dev/null +++ b/tests/test_settings_ksef.py @@ -0,0 +1,108 @@ +import base64 +from io import BytesIO + +from app.models.company import Company +from app.models.setting import AppSetting +from app.services.ksef_service import RequestsKSeFAdapter + + +def test_save_ksef_token_and_certificate(auth_client, app): + response = auth_client.post( + '/settings/', + data={ + 'ksef-base_url': ' https://api.ksef.mf.gov.pl ', + 'ksef-auth_mode': 'token', + 'ksef-client_id': ' client-1 ', + 'ksef-token': ' secret-token ', + 'ksef-submit': '1', + }, + content_type='multipart/form-data', + follow_redirects=True, + ) + assert response.status_code == 200 + + certificate_bytes = b"""-----BEGIN CERTIFICATE----- +TEST +-----END CERTIFICATE----- +""" + response = auth_client.post( + '/settings/', + data={ + 'ksef-base_url': 'https://api.ksef.mf.gov.pl/docs/v2', + 'ksef-auth_mode': 'certificate', + 'ksef-client_id': 'client-2', + 'ksef-token': '', + 'ksef-certificate_file': (BytesIO(certificate_bytes), 'cert.pem'), + 'ksef-submit': '1', + }, + content_type='multipart/form-data', + follow_redirects=True, + ) + assert response.status_code == 200 + + with app.app_context(): + company = Company.query.first() + prefix = f'company.{company.id}.ksef' + assert AppSetting.get(f'{prefix}.base_url') == 'https://api.ksef.mf.gov.pl/docs/v2' + assert AppSetting.get(f'{prefix}.auth_mode') == 'certificate' + assert AppSetting.get(f'{prefix}.client_id') == 'client-2' + assert AppSetting.get(f'{prefix}.token', decrypt=True) == 'secret-token' + assert AppSetting.get(f'{prefix}.certificate_name') == 'cert.pem' + stored = AppSetting.get(f'{prefix}.certificate_data', decrypt=True) + assert base64.b64decode(stored) == certificate_bytes + + +def test_ksef_settings_page_shows_saved_status(auth_client, app): + with app.app_context(): + company = Company.query.first() + AppSetting.set(f'company.{company.id}.ksef.token', 'abc', encrypt=True) + AppSetting.set(f'company.{company.id}.ksef.certificate_name', 'cert.pem') + AppSetting.set(f'company.{company.id}.ksef.certificate_data', 'Y2VydA==', encrypt=True) + AppSetting.set(f'company.{company.id}.ksef.mock_mode', 'true') + from app.extensions import db + db.session.commit() + + response = auth_client.get('/settings/') + body = response.get_data(as_text=True) + assert response.status_code == 200 + assert 'Token KSeF jest zapisany w konfiguracji tej firmy.' in body + assert 'Certyfikat KSeF jest zapisany w konfiguracji tej firmy.' in body + assert 'Wgrany plik: cert.pem' in body + + +def test_requests_adapter_uses_supported_invoice_endpoints(app, monkeypatch): + calls = [] + + def fake_request(self, method, path, params=None, json=None, accept='application/json'): + calls.append((method, path, params, json, accept)) + if path == '/invoices/query/metadata': + return { + 'invoices': [ + { + 'ksefNumber': '5555555555-20250828-010080615740-E4', + 'invoiceNumber': 'FV/1', + 'issueDate': '2025-08-27', + 'acquisitionDate': '2025-08-28T09:22:56+00:00', + 'seller': {'nip': '5555555555', 'name': 'Test Company 1'}, + 'netAmount': 100, + 'vatAmount': 23, + 'grossAmount': 123, + } + ] + } + if path == '/invoices/ksef/5555555555-20250828-010080615740-E4': + return '' + raise AssertionError(path) + + with app.app_context(): + company = Company.query.first() + AppSetting.set(f'company.{company.id}.ksef.mock_mode', 'false') + from app.extensions import db + db.session.commit() + monkeypatch.setattr(RequestsKSeFAdapter, '_request', fake_request) + documents = RequestsKSeFAdapter(company_id=company.id).list_documents() + + assert len(documents) == 1 + assert calls[0][1] == '/invoices/query/metadata' + assert calls[1][1] == '/invoices/ksef/5555555555-20250828-010080615740-E4' + assert calls[1][4] == 'application/xml' diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..06bf5fe --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,11 @@ +from app.models.company import Company +from app.models.invoice import Invoice +from app.services.sync_service import SyncService + + +def test_sync_creates_invoices(app): + with app.app_context(): + company = Company.query.first() + log = SyncService(company).run_manual_sync() + assert log.status == 'finished' + assert Invoice.query.filter_by(company_id=company.id).count() > 0